[Mono.Android] Tweak AndroidMessageHandler behavior for WCF support (#7785)

Context: https://github.com/xamarin/xamarin-android/issues/7230
Context: https://github.com/dotnet/runtime/issues/80935

When a WCF application invokes an endpoint which returns compressed
content, and `AndroidMessageHandler` is doing the network requests
([the default when `$(UseNativeHttpHandler)`=True][0]):

	var soapClient = new WebServiceSoapClient(WebServiceSoapClient.EndpointConfiguration.WebServiceSoap);
	//Async test
	var helloResponse = await soapClient.HelloWorldAsync();

then the method will throw:

	The formatter threw an exception while trying to deserialize the message: There was an error while trying to deserialize parameter http://tempuri.org/:HelloWorldResponse.
	---> There was an error deserializing the object of type ServiceReference1.HelloWorldResponseBody. Unexpected end of file. Following elements are not closed: HelloWorldResult, HelloWorldResponse, Body, Envelope. Line 1, position 298.
	   at System.Runtime.Serialization.XmlObjectSerializer.ReadObjectHandleExceptions(XmlReaderDelegator reader, Boolean verifyObjectName, DataContractResolver dataContractResolver)
	   at System.Runtime.Serialization.XmlObjectSerializer.ReadObjectHandleExceptions(XmlReaderDelegator reader, Boolean verifyObjectName)
	   at System.Runtime.Serialization.DataContractSerializer.ReadObject(XmlDictionaryReader reader, Boolean verifyObjectName)
	   at System.ServiceModel.Dispatcher.DataContractSerializerOperationFormatter.PartInfo.ReadObject(XmlDictionaryReader reader, XmlObjectSerializer serializer) in /_/src/System.Private.ServiceModel/src/System/ServiceModel/Dispatcher/DataContractSerializerOperationFormatter.cs:line 657
	   at System.ServiceModel.Dispatcher.DataContractSerializerOperationFormatter.PartInfo.ReadObject(XmlDictionaryReader reader) in /_/src/System.Private.ServiceModel/src/System/ServiceModel/Dispatcher/DataContractSerializerOperationFormatter.cs:line 652
	   at System.ServiceModel.Dispatcher.DataContractSerializerOperationFormatter.DeserializeParameterPart(XmlDictionaryReader reader, PartInfo part, Boolean isRequest) in /_/src/System.Private.ServiceModel/src/System/ServiceModel/Dispatcher/DataContractSerializerOperationFormatter.cs:line 521

The reason for this is that when `AndroidMessageHandler` creates a
wrapping decompression stream, it does not update `Content-Length` to
match the length of the decoded content, because it doesn't have a
way to know what the length is without first reading the stream to the
end, and that might prevent the end user to read the content.
(Additionally, I think the `Content-Length` header should reflect the
*original* content length, for the end user to be able to
interpret the response as it was sent.)

WCF, on the other hand, looks at the `Content-Length` header and, if
found, takes the value and reads only that many bytes from the content
stream and no more, which will almost always result in short reads and
failure to correctly interpret the response.

Workaround this issue by making `AndroidMessageHandler` behave the
same way as other handlers implemented in the BCL.  What they do in
this situation is remove the `Content-Length` header, making WCF
read the stream to the end.  Additionally, the clients remove the
compressed content encoding identifier from the `Content-Encoding`
header.

	var handler = new AndroidMessageHandler {
	    AutomaticDecompression = DecompressionMethods.All
	};
	var client    = new HttpClient (handler);
	var response  = await client.GetAsync ("https://httpbin.org/gzip");
	// response.Content.Headers won't contain Content-Length,
	// and response.Content.Headers.ContentEncoding won't contain `gzip`.

As a bonus, also adds support for decompression of responses
compressed with the `Brotli` compression which use the `br` encoding
ID in the `Content-Encoding` header.

[0]: https://learn.microsoft.com/en-us/dotnet/core/deploying/trimming/trimming-options?pivots=dotnet-7-0
This commit is contained in:
Marek Habersack 2023-03-02 13:44:06 +01:00 коммит произвёл GitHub
Родитель f007593864
Коммит 5d46685050
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: 4AEE18F83AFDEB23
3 изменённых файлов: 203 добавлений и 42 удалений

Просмотреть файл

@ -69,11 +69,46 @@ namespace Xamarin.Android.Net
public bool MethodChanged;
}
/// <summary>
/// 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.
/// </summary>
sealed class ContentState
{
public bool? RemoveContentLengthHeader;
/// <summary>
/// If this is `true`, then `NewContentEncodingHeaderValue` is entirely ignored
/// </summary>
public bool? RemoveContentEncodingHeader;
/// <summary>
/// New 'Content-Encoding' header value. Ignored if not null and empty.
/// </summary>
public List<string>? NewContentEncodingHeaderValue;
/// <summary>
/// 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
/// </summary>
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<string, string> headerSeparators = new Dictionary<string, string> {
["User-Agent"] = " ",
@ -82,9 +117,9 @@ namespace Xamarin.Android.Net
static readonly HashSet <string> known_content_headers = new HashSet <string> (StringComparer.OrdinalIgnoreCase) {
"Allow",
"Content-Disposition",
"Content-Encoding",
ContentEncodingHeaderName,
"Content-Language",
"Content-Length",
ContentLengthHeaderName,
"Content-Location",
"Content-MD5",
"Content-Range",
@ -571,6 +606,7 @@ namespace Xamarin.Android.Net
CancellationTokenRegistration cancelRegistration = default (CancellationTokenRegistration);
HttpStatusCode statusCode = HttpStatusCode.OK;
Uri? connectionUri = null;
var contentState = new ContentState ();
try {
cancelRegistration = cancellationToken.Register (() => {
@ -608,13 +644,13 @@ namespace Xamarin.Android.Net
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 @@ namespace Xamarin.Android.Net
}
}
CopyHeaders (httpConnection, ret);
CopyHeaders (httpConnection, ret, contentState);
ParseCookies (ret, connectionUri);
if (disposeRet) {
@ -661,8 +697,8 @@ namespace Xamarin.Android.Net
// 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 @@ namespace Xamarin.Android.Net
return ret;
}
CopyHeaders (httpConnection, ret);
CopyHeaders (httpConnection, ret, contentState);
ParseCookies (ret, connectionUri);
if (Logger.LogNet)
@ -684,29 +720,57 @@ namespace Xamarin.Android.Net
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<string> (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<string> (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 @@ namespace Xamarin.Android.Net
}
}
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 @@ namespace Xamarin.Android.Net
} else {
item_headers = response.Headers;
}
item_headers.TryAddWithoutValidation (key, headers [key]);
IEnumerable<string> 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 ();
}
/// <summary>
@ -1006,19 +1091,24 @@ namespace Xamarin.Android.Net
List <string>? 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)

Просмотреть файл

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

Просмотреть файл

@ -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 @@ namespace Xamarin.Android.NetTests
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 ()
{