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 () {