diff --git a/src/Mono.Android/Xamarin.Android.Net/AndroidMessageHandler.cs b/src/Mono.Android/Xamarin.Android.Net/AndroidMessageHandler.cs
index 8971220b68b..7d42441c66e 100644
--- a/src/Mono.Android/Xamarin.Android.Net/AndroidMessageHandler.cs
+++ b/src/Mono.Android/Xamarin.Android.Net/AndroidMessageHandler.cs
@@ -69,11 +69,46 @@ sealed class RequestRedirectionState
public bool MethodChanged;
}
+ ///
+ /// Some requests require modification to the set of headers returned from the native client.
+ /// However, the headers collection in it is immutable, so we need to perform the adjustments
+ /// in CopyHeaders. This class describes the necessary operations.
+ ///
+ sealed class ContentState
+ {
+ public bool? RemoveContentLengthHeader;
+
+ ///
+ /// If this is `true`, then `NewContentEncodingHeaderValue` is entirely ignored
+ ///
+ public bool? RemoveContentEncodingHeader;
+
+ ///
+ /// New 'Content-Encoding' header value. Ignored if not null and empty.
+ ///
+ public List? NewContentEncodingHeaderValue;
+
+ ///
+ /// Reset the class to values that indicate there's no action to take. MUST be
+ /// called BEFORE any of the class members are assigned values and AFTER the state
+ /// modification is applied
+ ///
+ public void Reset ()
+ {
+ RemoveContentEncodingHeader = null;
+ RemoveContentLengthHeader = null;
+ NewContentEncodingHeaderValue = null;
+ }
+ }
+
internal const string LOG_APP = "monodroid-net";
const string GZIP_ENCODING = "gzip";
const string DEFLATE_ENCODING = "deflate";
+ const string BROTLI_ENCODING = "br";
const string IDENTITY_ENCODING = "identity";
+ const string ContentEncodingHeaderName = "Content-Encoding";
+ const string ContentLengthHeaderName = "Content-Length";
static readonly IDictionary headerSeparators = new Dictionary {
["User-Agent"] = " ",
@@ -82,9 +117,9 @@ sealed class RequestRedirectionState
static readonly HashSet known_content_headers = new HashSet (StringComparer.OrdinalIgnoreCase) {
"Allow",
"Content-Disposition",
- "Content-Encoding",
+ ContentEncodingHeaderName,
"Content-Language",
- "Content-Length",
+ ContentLengthHeaderName,
"Content-Location",
"Content-MD5",
"Content-Range",
@@ -571,6 +606,7 @@ internal Task WriteRequestContentToOutputInternal (HttpRequestMessage request, H
CancellationTokenRegistration cancelRegistration = default (CancellationTokenRegistration);
HttpStatusCode statusCode = HttpStatusCode.OK;
Uri? connectionUri = null;
+ var contentState = new ContentState ();
try {
cancelRegistration = cancellationToken.Register (() => {
@@ -608,13 +644,13 @@ internal Task WriteRequestContentToOutputInternal (HttpRequestMessage request, H
if (!IsErrorStatusCode (statusCode)) {
if (Logger.LogNet)
Logger.Log (LogLevel.Info, LOG_APP, $"Reading...");
- ret.Content = GetContent (httpConnection, httpConnection.InputStream!);
+ ret.Content = GetContent (httpConnection, httpConnection.InputStream!, contentState);
} else {
if (Logger.LogNet)
Logger.Log (LogLevel.Info, LOG_APP, $"Status code is {statusCode}, reading...");
// For 400 >= response code <= 599 the Java client throws the FileNotFound exception when attempting to read from the input stream.
// Instead we try to read the error stream and return an empty string if the error stream isn't readable.
- ret.Content = GetErrorContent (httpConnection, new StringContent (String.Empty, Encoding.ASCII));
+ ret.Content = GetErrorContent (httpConnection, new StringContent (String.Empty, Encoding.ASCII), contentState);
}
bool disposeRet;
@@ -633,7 +669,7 @@ internal Task WriteRequestContentToOutputInternal (HttpRequestMessage request, H
}
}
- CopyHeaders (httpConnection, ret);
+ CopyHeaders (httpConnection, ret, contentState);
ParseCookies (ret, connectionUri);
if (disposeRet) {
@@ -661,8 +697,8 @@ internal Task WriteRequestContentToOutputInternal (HttpRequestMessage request, H
// We return the body of the response too, but the Java client will throw
// a FileNotFound exception if we attempt to access the input stream.
// Instead we try to read the error stream and return an default message if the error stream isn't readable.
- ret.Content = GetErrorContent (httpConnection, new StringContent ("Unauthorized", Encoding.ASCII));
- CopyHeaders (httpConnection, ret);
+ ret.Content = GetErrorContent (httpConnection, new StringContent ("Unauthorized", Encoding.ASCII), contentState);
+ CopyHeaders (httpConnection, ret, contentState);
if (ret.Headers.WwwAuthenticate != null) {
ProxyAuthenticationRequested = false;
@@ -676,7 +712,7 @@ internal Task WriteRequestContentToOutputInternal (HttpRequestMessage request, H
return ret;
}
- CopyHeaders (httpConnection, ret);
+ CopyHeaders (httpConnection, ret, contentState);
ParseCookies (ret, connectionUri);
if (Logger.LogNet)
@@ -684,29 +720,57 @@ internal Task WriteRequestContentToOutputInternal (HttpRequestMessage request, H
return ret;
}
- HttpContent GetErrorContent (HttpURLConnection httpConnection, HttpContent fallbackContent)
+ HttpContent GetErrorContent (HttpURLConnection httpConnection, HttpContent fallbackContent, ContentState contentState)
{
var contentStream = httpConnection.ErrorStream;
if (contentStream != null) {
- return GetContent (httpConnection, contentStream);
+ return GetContent (httpConnection, contentStream, contentState);
}
return fallbackContent;
}
- HttpContent GetContent (URLConnection httpConnection, Stream contentStream)
+ Stream GetDecompressionWrapper (URLConnection httpConnection, Stream inputStream, ContentState contentState)
{
- Stream inputStream = new BufferedStream (contentStream);
- if (decompress_here) {
- var encodings = httpConnection.ContentEncoding?.Split (',');
- if (encodings != null) {
- if (encodings.Contains (GZIP_ENCODING, StringComparer.OrdinalIgnoreCase))
- inputStream = new GZipStream (inputStream, CompressionMode.Decompress);
- else if (encodings.Contains (DEFLATE_ENCODING, StringComparer.OrdinalIgnoreCase))
- inputStream = new DeflateStream (inputStream, CompressionMode.Decompress);
+ contentState.Reset ();
+ if (!decompress_here || String.IsNullOrEmpty (httpConnection.ContentEncoding)) {
+ return inputStream;
+ }
+
+ var encodings = new HashSet (httpConnection.ContentEncoding?.Split (','), StringComparer.OrdinalIgnoreCase);
+ Stream? ret = null;
+ string? supportedEncoding = null;
+ if (encodings.Contains (GZIP_ENCODING)) {
+ supportedEncoding = GZIP_ENCODING;
+ ret = new GZipStream (inputStream, CompressionMode.Decompress);
+ } else if (encodings.Contains (DEFLATE_ENCODING)) {
+ supportedEncoding = DEFLATE_ENCODING;
+ ret = new DeflateStream (inputStream, CompressionMode.Decompress);
+ }
+#if NETCOREAPP
+ else if (encodings.Contains (BROTLI_ENCODING)) {
+ supportedEncoding = BROTLI_ENCODING;
+ ret = new BrotliStream (inputStream, CompressionMode.Decompress);
+ }
+#endif
+ if (!String.IsNullOrEmpty (supportedEncoding)) {
+ contentState.RemoveContentLengthHeader = true;
+
+ encodings.Remove (supportedEncoding!);
+ if (encodings.Count == 0) {
+ contentState.RemoveContentEncodingHeader = true;
+ } else {
+ contentState.NewContentEncodingHeaderValue = new List (encodings);
}
}
+
+ return ret ?? inputStream;
+ }
+
+ HttpContent GetContent (URLConnection httpConnection, Stream contentStream, ContentState contentState)
+ {
+ Stream inputStream = GetDecompressionWrapper (httpConnection, new BufferedStream (contentStream), contentState);
return new StreamContent (inputStream);
}
@@ -881,9 +945,13 @@ void ParseCookies (AndroidHttpResponseMessage ret, Uri connectionUri)
}
}
- void CopyHeaders (HttpURLConnection httpConnection, HttpResponseMessage response)
+ void CopyHeaders (HttpURLConnection httpConnection, HttpResponseMessage response, ContentState contentState)
{
var headers = httpConnection.HeaderFields;
+ bool removeContentLength = contentState.RemoveContentLengthHeader ?? false;
+ bool removeContentEncoding = contentState.RemoveContentEncodingHeader ?? false;
+ bool setNewContentEncodingValue = !removeContentEncoding && contentState.NewContentEncodingHeaderValue != null && contentState.NewContentEncodingHeaderValue.Count > 0;
+
foreach (var key in headers!.Keys) {
if (key == null) // First header entry has null key, it corresponds to the response message
continue;
@@ -895,8 +963,25 @@ void CopyHeaders (HttpURLConnection httpConnection, HttpResponseMessage response
} else {
item_headers = response.Headers;
}
- item_headers.TryAddWithoutValidation (key, headers [key]);
+
+ IEnumerable values = headers [key];
+ if (removeContentLength && String.Compare (ContentLengthHeaderName, key, StringComparison.OrdinalIgnoreCase) == 0) {
+ removeContentLength = false;
+ continue;
+ }
+
+ if ((removeContentEncoding || setNewContentEncodingValue) && String.Compare (ContentEncodingHeaderName, key, StringComparison.OrdinalIgnoreCase) == 0) {
+ if (removeContentEncoding) {
+ removeContentEncoding = false;
+ continue;
+ }
+
+ setNewContentEncodingValue = false;
+ values = contentState.NewContentEncodingHeaderValue!;
+ }
+ item_headers.TryAddWithoutValidation (key, values);
}
+ contentState.Reset ();
}
///
@@ -1006,19 +1091,24 @@ void AppendEncoding (string encoding, ref List ? list)
List ? accept_encoding = null;
decompress_here = false;
- if ((AutomaticDecompression & DecompressionMethods.GZip) != 0) {
- AppendEncoding (GZIP_ENCODING, ref accept_encoding);
- decompress_here = true;
- }
-
- if ((AutomaticDecompression & DecompressionMethods.Deflate) != 0) {
- AppendEncoding (DEFLATE_ENCODING, ref accept_encoding);
- decompress_here = true;
- }
-
if (AutomaticDecompression == DecompressionMethods.None) {
- accept_encoding?.Clear ();
AppendEncoding (IDENTITY_ENCODING, ref accept_encoding); // Turns off compression for the Java client
+ } else {
+ if ((AutomaticDecompression & DecompressionMethods.GZip) != 0) {
+ AppendEncoding (GZIP_ENCODING, ref accept_encoding);
+ decompress_here = true;
+ }
+
+ if ((AutomaticDecompression & DecompressionMethods.Deflate) != 0) {
+ AppendEncoding (DEFLATE_ENCODING, ref accept_encoding);
+ decompress_here = true;
+ }
+#if NETCOREAPP
+ if ((AutomaticDecompression & DecompressionMethods.Brotli) != 0) {
+ AppendEncoding (BROTLI_ENCODING, ref accept_encoding);
+ decompress_here = true;
+ }
+#endif
}
if (accept_encoding?.Count > 0)
diff --git a/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.ProjectTools/Resources/Base/BuildReleaseArm64XFormsDotNet.apkdesc b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.ProjectTools/Resources/Base/BuildReleaseArm64XFormsDotNet.apkdesc
index b3fc11fd0e4..6902b06169c 100644
--- a/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.ProjectTools/Resources/Base/BuildReleaseArm64XFormsDotNet.apkdesc
+++ b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.ProjectTools/Resources/Base/BuildReleaseArm64XFormsDotNet.apkdesc
@@ -11,13 +11,13 @@
"Size": 7313
},
"assemblies/Java.Interop.dll": {
- "Size": 66563
+ "Size": 66562
},
"assemblies/Mono.Android.dll": {
- "Size": 444617
+ "Size": 444972
},
"assemblies/Mono.Android.Runtime.dll": {
- "Size": 5897
+ "Size": 5822
},
"assemblies/mscorlib.dll": {
"Size": 3866
@@ -64,6 +64,9 @@
"assemblies/System.Drawing.Primitives.dll": {
"Size": 12010
},
+ "assemblies/System.IO.Compression.Brotli.dll": {
+ "Size": 11871
+ },
"assemblies/System.IO.Compression.dll": {
"Size": 16858
},
@@ -89,7 +92,7 @@
"Size": 8154
},
"assemblies/System.Private.CoreLib.dll": {
- "Size": 814216
+ "Size": 814322
},
"assemblies/System.Private.DataContractSerialization.dll": {
"Size": 192370
@@ -131,7 +134,7 @@
"Size": 1864
},
"assemblies/UnnamedProject.dll": {
- "Size": 5294
+ "Size": 5286
},
"assemblies/Xamarin.AndroidX.Activity.dll": {
"Size": 5867
@@ -206,7 +209,7 @@
"Size": 93552
},
"lib/arm64-v8a/libmonodroid.so": {
- "Size": 379152
+ "Size": 380656
},
"lib/arm64-v8a/libmonosgen-2.0.so": {
"Size": 3106808
@@ -221,7 +224,7 @@
"Size": 154904
},
"lib/arm64-v8a/libxamarin-app.so": {
- "Size": 333760
+ "Size": 333840
},
"META-INF/android.support.design_material.version": {
"Size": 12
@@ -335,13 +338,13 @@
"Size": 1213
},
"META-INF/BNDLTOOL.SF": {
- "Size": 79326
+ "Size": 79441
},
"META-INF/com.google.android.material_material.version": {
"Size": 10
},
"META-INF/MANIFEST.MF": {
- "Size": 79199
+ "Size": 79314
},
"META-INF/proguard/androidx-annotations.pro": {
"Size": 339
@@ -1976,5 +1979,5 @@
"Size": 341228
}
},
- "PackageSize": 7820036
+ "PackageSize": 7832413
}
\ No newline at end of file
diff --git a/tests/Mono.Android-Tests/Xamarin.Android.Net/AndroidMessageHandlerTests.cs b/tests/Mono.Android-Tests/Xamarin.Android.Net/AndroidMessageHandlerTests.cs
index c15b8292c36..c240ac94ccb 100644
--- a/tests/Mono.Android-Tests/Xamarin.Android.Net/AndroidMessageHandlerTests.cs
+++ b/tests/Mono.Android-Tests/Xamarin.Android.Net/AndroidMessageHandlerTests.cs
@@ -1,4 +1,5 @@
using System;
+using System.Net;
using System.Net.Http;
using System.Net.Security;
using System.Security.Cryptography.X509Certificates;
@@ -19,6 +20,73 @@ protected override HttpMessageHandler CreateHandler ()
return new AndroidMessageHandler ();
}
+ // We can't test `deflate` for now because it's broken in the BCL for https://httpbin.org/deflate (S.I.Compression.DeflateStream doesn't recognize the compression
+ // method used by the server)
+ static readonly object[] DecompressionSource = new object[] {
+ new object[] {
+ "gzip", // urlPath
+ "gzip", // encoding
+ "gzipped", // jsonFieldName
+ },
+
+ new object[] {
+ "brotli", // urlPath
+ "br", // encoding
+ "brotli", // jsonFieldName
+ },
+ };
+
+#if NET
+ [Test]
+ [TestCaseSource (nameof (DecompressionSource))]
+ [Retry (5)]
+ public async Task Decompression (string urlPath, string encoding, string jsonFieldName)
+ {
+ // Catch all the exceptions and warn about them or otherwise [Retry] above won't work
+ try {
+ DoDecompression (urlPath, encoding, jsonFieldName);
+ } catch (Exception ex) {
+ Assert.Warn ("Unexpected exception thrown");
+ Assert.Warn (ex.ToString ());
+ Assert.Fail ("Exception should have not been thrown");
+ }
+ }
+
+ void DoDecompression (string urlPath, string encoding, string jsonFieldName)
+ {
+ var handler = new AndroidMessageHandler {
+ AutomaticDecompression = DecompressionMethods.All
+ };
+
+ var client = new HttpClient (handler);
+ HttpResponseMessage response = await client.GetAsync ($"https://httpbin.org/{urlPath}");
+
+ // Failing on error codes other than 2xx will make NUnit retry the test up to the number of times specified in the
+ // [Retry] attribute above. This may or may not the desired effect if httpbin.org is throttling the requests, thus
+ // we will sleep a short while before failing the test
+ if (!response.IsSuccessStatusCode) {
+ System.Threading.Thread.Sleep (1000);
+ Assert.Fail ($"Request ended with a failure error code: {response.StatusCode}");
+ }
+
+ foreach (string enc in response.Content.Headers.ContentEncoding) {
+ if (String.Compare (enc, encoding, StringComparison.Ordinal) == 0) {
+ Assert.Fail ($"Encoding '{encoding}' should have been removed from the Content-Encoding header");
+ }
+ }
+
+ string responseBody = await response.Content.ReadAsStringAsync ();
+
+ Assert.Warn ("-- Retrieved JSON start");
+ Assert.Warn (responseBody);
+ Assert.Warn ("-- Retrieved JSON end");
+
+ Assert.IsTrue (responseBody.Length > 0, "Response was empty");
+ Assert.AreEqual (response.Content.Headers.ContentLength, responseBody.Length, "Retrieved data length is different than the one specified in the Content-Length header");
+ Assert.IsTrue (responseBody.Contains ($"\"{jsonFieldName}\"", StringComparison.OrdinalIgnoreCase), $"\"{jsonFieldName}\" should have been in the response JSON");
+ }
+#endif
+
[Test]
public async Task ServerCertificateCustomValidationCallback_ApproveRequest ()
{