[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:
Родитель
f007593864
Коммит
5d46685050
|
@ -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 ()
|
||||
{
|
||||
|
|
Загрузка…
Ссылка в новой задаче