Browse Source

Initial support for `async IAsyncEnumerator<T>` methods

pull/1730/head
Daniel Grunwald 6 years ago
parent
commit
f8ee7c2bf3
  1. 2
      ICSharpCode.Decompiler/CSharp/CSharpDecompiler.cs
  2. 23
      ICSharpCode.Decompiler/DecompilerSettings.cs
  3. 269
      ICSharpCode.Decompiler/IL/ControlFlow/AsyncAwaitDecompiler.cs
  4. 2
      ICSharpCode.Decompiler/IL/ControlFlow/AwaitInCatchTransform.cs
  5. 10
      ICSharpCode.Decompiler/IL/ControlFlow/YieldReturnDecompiler.cs
  6. 18
      ILSpy/Properties/Resources.Designer.cs
  7. 6
      ILSpy/Properties/Resources.resx

2
ICSharpCode.Decompiler/CSharp/CSharpDecompiler.cs

@ -305,6 +305,8 @@ namespace ICSharpCode.Decompiler.CSharp @@ -305,6 +305,8 @@ namespace ICSharpCode.Decompiler.CSharp
return true;
if (settings.AsyncAwait && AsyncAwaitDecompiler.IsCompilerGeneratedStateMachine(typeHandle, metadata))
return true;
if (settings.AsyncEnumerator && AsyncAwaitDecompiler.IsCompilerGeneratorAsyncEnumerator(typeHandle, metadata))
return true;
if (settings.FixedBuffers && name.StartsWith("<", StringComparison.Ordinal) && name.Contains("__FixedBuffer"))
return true;
} else if (type.IsCompilerGenerated(metadata)) {

23
ICSharpCode.Decompiler/DecompilerSettings.cs

@ -108,12 +108,13 @@ namespace ICSharpCode.Decompiler @@ -108,12 +108,13 @@ namespace ICSharpCode.Decompiler
if (languageVersion < CSharp.LanguageVersion.CSharp8_0) {
nullableReferenceTypes = false;
readOnlyMethods = false;
asyncEnumerator = false;
}
}
public CSharp.LanguageVersion GetMinimumRequiredVersion()
{
if (nullableReferenceTypes || readOnlyMethods)
if (nullableReferenceTypes || readOnlyMethods || asyncEnumerator)
return CSharp.LanguageVersion.CSharp8_0;
if (introduceUnmanagedConstraint || tupleComparisons || stackAllocInitializers)
return CSharp.LanguageVersion.CSharp7_3;
@ -273,6 +274,24 @@ namespace ICSharpCode.Decompiler @@ -273,6 +274,24 @@ namespace ICSharpCode.Decompiler
}
}
bool asyncEnumerator = true;
/// <summary>
/// Decompile IAsyncEnumerator/IAsyncEnumerable.
/// Only has an effect if <see cref="AsyncAwait"/> is enabled.
/// </summary>
[Category("C# 8.0 / VS 2019")]
[Description("DecompilerSettings.AsyncEnumerator")]
public bool AsyncEnumerator {
get { return asyncEnumerator; }
set {
if (asyncEnumerator != value) {
asyncEnumerator = value;
OnPropertyChanged();
}
}
}
bool decimalConstants = true;
/// <summary>
@ -846,7 +865,7 @@ namespace ICSharpCode.Decompiler @@ -846,7 +865,7 @@ namespace ICSharpCode.Decompiler
bool readOnlyMethods = true;
[Category("C# 8.0 / VS 2019")]
[Description("DecompilerSettings.IsReadOnlyAttributeShouldBeReplacedWithReadonlyInModifiersOnStructsParameters")]
[Description("DecompilerSettings.ReadOnlyMethods")]
public bool ReadOnlyMethods {
get { return readOnlyMethods; }
set {

269
ICSharpCode.Decompiler/IL/ControlFlow/AsyncAwaitDecompiler.cs

@ -62,14 +62,16 @@ namespace ICSharpCode.Decompiler.IL.ControlFlow @@ -62,14 +62,16 @@ namespace ICSharpCode.Decompiler.IL.ControlFlow
{
Void,
Task,
TaskOfT
TaskOfT,
AsyncEnumerator,
AsyncEnumerable
}
ILTransformContext context;
// These fields are set by MatchTaskCreationPattern()
IType taskType; // return type of the async method
IType underlyingReturnType; // return type of the method (only the "T" for Task{T})
// These fields are set by MatchTaskCreationPattern() or MatchEnumeratorCreationNewObj()
IType taskType; // return type of the async method; or IAsyncEnumerable{T}/IAsyncEnumerator{T}
IType underlyingReturnType; // return type of the method (only the "T" for Task{T}), for async enumerators this is the type being yielded
AsyncMethodType methodType;
ITypeDefinition stateMachineType;
ITypeDefinition builderType;
@ -84,9 +86,11 @@ namespace ICSharpCode.Decompiler.IL.ControlFlow @@ -84,9 +86,11 @@ namespace ICSharpCode.Decompiler.IL.ControlFlow
ILVariable cachedStateVar; // variable in MoveNext that caches the stateField.
TryCatch mainTryCatch;
Block setResultAndExitBlock; // block that is jumped to for return statements
// Note: for async enumerators, a jump to setResultAndExitBlock is a 'yield break;'
int finalState; // final state after the setResultAndExitBlock
bool finalStateKnown;
ILVariable resultVar; // the variable that gets returned by the setResultAndExitBlock
Block setResultYieldBlock; // block that is jumped to for 'yield return' statements
ILVariable doFinallyBodies;
// These fields are set by AnalyzeStateMachine():
@ -110,7 +114,7 @@ namespace ICSharpCode.Decompiler.IL.ControlFlow @@ -110,7 +114,7 @@ namespace ICSharpCode.Decompiler.IL.ControlFlow
awaitBlocks.Clear();
awaitDebugInfos.Clear();
moveNextLeaves.Clear();
if (!MatchTaskCreationPattern(function))
if (!MatchTaskCreationPattern(function) && !MatchAsyncEnumeratorCreationPattern(function))
return;
try {
AnalyzeMoveNext();
@ -133,7 +137,11 @@ namespace ICSharpCode.Decompiler.IL.ControlFlow @@ -133,7 +137,11 @@ namespace ICSharpCode.Decompiler.IL.ControlFlow
TranslateCachedFieldsToLocals();
FinalizeInlineMoveNext(function);
if (methodType == AsyncMethodType.AsyncEnumerable || methodType == AsyncMethodType.AsyncEnumerator) {
((BlockContainer)function.Body).ExpectedResultType = StackType.Void;
} else {
((BlockContainer)function.Body).ExpectedResultType = underlyingReturnType.GetStackType();
}
// Re-run control flow simplification over the newly constructed set of gotos,
// and inlining because TranslateFieldsToLocalAccess() might have opened up new inlining opportunities.
@ -337,6 +345,118 @@ namespace ICSharpCode.Decompiler.IL.ControlFlow @@ -337,6 +345,118 @@ namespace ICSharpCode.Decompiler.IL.ControlFlow
}
#endregion
#region MatchAsyncEnumeratorCreationPattern
private bool MatchAsyncEnumeratorCreationPattern(ILFunction function)
{
if (!context.Settings.AsyncEnumerator)
return false;
taskType = function.ReturnType;
if (taskType.IsKnownType(KnownTypeCode.IAsyncEnumeratorOfT)) {
methodType = AsyncMethodType.AsyncEnumerator;
} else if (taskType.IsKnownType(KnownTypeCode.IAsyncEnumerableOfT)) {
methodType = AsyncMethodType.AsyncEnumerable;
} else {
return false;
}
underlyingReturnType = taskType.TypeArguments.Single();
if (!(function.Body is BlockContainer blockContainer))
return false;
if (blockContainer.Blocks.Count != 1)
return false;
var body = blockContainer.EntryPoint;
if (body.Instructions.Count == 1) {
// No parameters passed to enumerator (not even 'this'):
// ret(newobj(...))
if (!body.Instructions[0].MatchReturn(out var newObj))
return false;
if (MatchEnumeratorCreationNewObj(newObj, context, out initialState, out stateMachineType)) {
// HACK: the normal async/await logic expects 'initialState' to be the 'in progress' state
initialState = -1;
try {
AnalyzeEnumeratorCtor(((NewObj)newObj).Method, context, out builderField, out stateField);
} catch (SymbolicAnalysisFailedException) {
return false;
}
builderType = builderField.Type.GetDefinition();
if (builderType == null)
return false;
return true;
} else {
return false;
}
}
// TODO: enumerator creation with parameters
return false;
}
static bool MatchEnumeratorCreationNewObj(ILInstruction inst, ILTransformContext context,
out int initialState, out ITypeDefinition stateMachineType)
{
initialState = default;
stateMachineType = default;
// newobj(CurrentType/...::.ctor, ldc.i4(-2))
if (!(inst is NewObj newObj))
return false;
if (newObj.Arguments.Count != 1)
return false;
if (!newObj.Arguments[0].MatchLdcI4(out initialState))
return false;
stateMachineType = newObj.Method.DeclaringTypeDefinition;
if (stateMachineType == null)
return false;
if (stateMachineType.DeclaringTypeDefinition != context.Function.Method.DeclaringTypeDefinition)
return false;
return IsCompilerGeneratorAsyncEnumerator(
(TypeDefinitionHandle)stateMachineType.MetadataToken,
context.TypeSystem.MainModule.metadata);
}
public static bool IsCompilerGeneratorAsyncEnumerator(TypeDefinitionHandle type, MetadataReader metadata)
{
TypeDefinition td;
if (type.IsNil || !type.IsCompilerGeneratedOrIsInCompilerGeneratedClass(metadata) || (td = metadata.GetTypeDefinition(type)).GetDeclaringType().IsNil)
return false;
foreach (var i in td.GetInterfaceImplementations()) {
var tr = metadata.GetInterfaceImplementation(i).Interface.GetFullTypeName(metadata);
if (!tr.IsNested && tr.TopLevelTypeName.Namespace == "System.Collections.Generic" && tr.TopLevelTypeName.Name == "IAsyncEnumerator" && tr.TopLevelTypeName.TypeParameterCount == 1)
return true;
}
return false;
}
static void AnalyzeEnumeratorCtor(IMethod ctor, ILTransformContext context, out IField builderField, out IField stateField)
{
builderField = null;
stateField = null;
var ctorHandle = (MethodDefinitionHandle)ctor.MetadataToken;
Block body = YieldReturnDecompiler.SingleBlock(YieldReturnDecompiler.CreateILAst(ctorHandle, context).Body);
if (body == null)
throw new SymbolicAnalysisFailedException("Missing enumeratorCtor.Body");
// Block IL_0000 (incoming: 1) {
// call Object..ctor(ldloc this)
// stfld <>1__state(ldloc this, ldloc <>1__state)
// stfld <>t__builder(ldloc this, call Create())
// leave IL_0000 (nop)
// }
if (body.Instructions.ElementAtOrDefault(1).MatchStFld(out var target, out var field, out var value)
&& target.MatchLdThis()
&& value.MatchLdLoc(out var arg)
&& arg.Kind == VariableKind.Parameter && arg.Index == 0) {
stateField = (IField)field.MemberDefinition;
} else {
throw new SymbolicAnalysisFailedException("Could not find stateField");
}
if (body.Instructions.ElementAtOrDefault(2).MatchStFld(out target, out field, out value)
&& target.MatchLdThis()
&& value is Call call && call.Method.Name == "Create") {
builderField = (IField)field.MemberDefinition;
} else {
throw new SymbolicAnalysisFailedException("Could not find builderField");
}
}
#endregion
#region AnalyzeMoveNext
/// <summary>
/// First peek into MoveNext(); analyzes everything outside the big try-catch.
@ -353,8 +473,6 @@ namespace ICSharpCode.Decompiler.IL.ControlFlow @@ -353,8 +473,6 @@ namespace ICSharpCode.Decompiler.IL.ControlFlow
moveNextFunction = YieldReturnDecompiler.CreateILAst(moveNextMethod, context);
if (!(moveNextFunction.Body is BlockContainer blockContainer))
throw new SymbolicAnalysisFailedException();
if (blockContainer.Blocks.Count != 2 && blockContainer.Blocks.Count != 1)
throw new SymbolicAnalysisFailedException();
if (blockContainer.EntryPoint.IncomingEdgeCount != 1)
throw new SymbolicAnalysisFailedException();
cachedStateVar = null;
@ -385,12 +503,49 @@ namespace ICSharpCode.Decompiler.IL.ControlFlow @@ -385,12 +503,49 @@ namespace ICSharpCode.Decompiler.IL.ControlFlow
if (((BlockContainer)mainTryCatch.TryBlock).EntryPoint.Instructions[0] is StLoc initDoFinallyBodies
&& initDoFinallyBodies.Variable.Kind == VariableKind.Local
&& initDoFinallyBodies.Variable.Type.IsKnownType(KnownTypeCode.Boolean)
&& initDoFinallyBodies.Value.MatchLdcI4(1))
{
&& initDoFinallyBodies.Value.MatchLdcI4(1)) {
doFinallyBodies = initDoFinallyBodies.Variable;
}
setResultAndExitBlock = blockContainer.Blocks.ElementAtOrDefault(1);
Debug.Assert(blockContainer.Blocks[0] == blockContainer.EntryPoint); // already checked this block
pos = 1;
if (MatchYieldBlock(blockContainer, pos)) {
setResultYieldBlock = blockContainer.Blocks[pos];
pos++;
} else {
setResultYieldBlock = null;
}
setResultAndExitBlock = blockContainer.Blocks.ElementAtOrDefault(pos);
CheckSetResultAndExitBlock(blockContainer);
if (pos + 1 < blockContainer.Blocks.Count)
throw new SymbolicAnalysisFailedException("too many blocks");
}
private bool IsAsyncEnumerator => methodType == AsyncMethodType.AsyncEnumerable || methodType == AsyncMethodType.AsyncEnumerator;
bool MatchYieldBlock(BlockContainer blockContainer, int pos)
{
if (!IsAsyncEnumerator)
return false;
var block = blockContainer.Blocks.ElementAtOrDefault(pos);
// call SetResult(ldflda <>v__promiseOfValueOrEnd(ldloc this), ldc.i4 1)
// leave IL_0000(nop)
if (block.Instructions.Count != 2)
return false;
if (!MatchCall(block.Instructions[0], "SetResult", out var args))
return false;
if (args.Count != 2)
return false;
if (!IsBuilderOrPromiseFieldOnThis(args[0]))
return false;
if (!args[1].MatchLdcI4(1))
return false;
return block.Instructions[1].MatchLeave(blockContainer);
}
void CheckSetResultAndExitBlock(BlockContainer blockContainer)
{
if (setResultAndExitBlock == null) {
// This block can be absent if the function never exits normally,
// but always throws an exception/loops infinitely.
@ -408,17 +563,29 @@ namespace ICSharpCode.Decompiler.IL.ControlFlow @@ -408,17 +563,29 @@ namespace ICSharpCode.Decompiler.IL.ControlFlow
finalStateKnown = true;
if (!MatchCall(setResultAndExitBlock.Instructions[1], "SetResult", out var args))
throw new SymbolicAnalysisFailedException();
if (!IsBuilderFieldOnThis(args[0]))
if (!IsBuilderOrPromiseFieldOnThis(args[0]))
throw new SymbolicAnalysisFailedException();
if (methodType == AsyncMethodType.TaskOfT) {
switch (methodType) {
case AsyncMethodType.TaskOfT:
if (args.Count != 2)
throw new SymbolicAnalysisFailedException();
if (!args[1].MatchLdLoc(out resultVar))
throw new SymbolicAnalysisFailedException();
} else {
break;
case AsyncMethodType.Task:
case AsyncMethodType.Void:
resultVar = null;
if (args.Count != 1)
throw new SymbolicAnalysisFailedException();
break;
case AsyncMethodType.AsyncEnumerable:
case AsyncMethodType.AsyncEnumerator:
resultVar = null;
if (args.Count != 2)
throw new SymbolicAnalysisFailedException();
if (!args[1].MatchLdcI4(0))
throw new SymbolicAnalysisFailedException();
break;
}
if (!setResultAndExitBlock.Instructions[2].MatchLeave(blockContainer))
throw new SymbolicAnalysisFailedException();
@ -465,7 +632,7 @@ namespace ICSharpCode.Decompiler.IL.ControlFlow @@ -465,7 +632,7 @@ namespace ICSharpCode.Decompiler.IL.ControlFlow
throw new SymbolicAnalysisFailedException();
if (args.Count != 2)
throw new SymbolicAnalysisFailedException();
if (!IsBuilderFieldOnThis(args[0]))
if (!IsBuilderOrPromiseFieldOnThis(args[0]))
throw new SymbolicAnalysisFailedException();
if (!args[1].MatchLdLoc(stloc.Variable))
throw new SymbolicAnalysisFailedException();
@ -490,13 +657,22 @@ namespace ICSharpCode.Decompiler.IL.ControlFlow @@ -490,13 +657,22 @@ namespace ICSharpCode.Decompiler.IL.ControlFlow
return target.MatchLdThis() && field.MemberDefinition == builderField;
}
bool IsBuilderOrPromiseFieldOnThis(ILInstruction inst)
{
if (methodType == AsyncMethodType.AsyncEnumerable || methodType == AsyncMethodType.AsyncEnumerator) {
return true; // TODO: check against uses of promise fields in other methods?
} else {
return IsBuilderFieldOnThis(inst);
}
}
bool MatchStateAssignment(ILInstruction inst, out int newState)
{
// stfld(StateMachine::<>1__state, ldloc(this), ldc.i4(stateId))
if (inst.MatchStFld(out var target, out var field, out var value)
&& target.MatchLdThis()
&& StackSlotValue(target).MatchLdThis()
&& field.MemberDefinition == stateField
&& value.MatchLdcI4(out newState))
&& StackSlotValue(value).MatchLdcI4(out newState))
{
return true;
}
@ -520,6 +696,10 @@ namespace ICSharpCode.Decompiler.IL.ControlFlow @@ -520,6 +696,10 @@ namespace ICSharpCode.Decompiler.IL.ControlFlow
branch.ReplaceWith(new Leave((BlockContainer)function.Body, resultVar == null ? null : new LdLoc(resultVar)).WithILRange(branch));
}
}
if (setResultYieldBlock != null) {
// We still might have branches to this block; and we can't quite yet get rid of it.
((BlockContainer)function.Body).Blocks.Add(setResultYieldBlock);
}
foreach (var leave in function.Descendants.OfType<Leave>()) {
if (leave.TargetContainer == moveNextFunction.Body) {
leave.TargetContainer = (BlockContainer)function.Body;
@ -591,6 +771,19 @@ namespace ICSharpCode.Decompiler.IL.ControlFlow @@ -591,6 +771,19 @@ namespace ICSharpCode.Decompiler.IL.ControlFlow
smallestAwaiterVarIndex = awaiterVar.Index.Value;
}
}
} else if (block.Instructions.Last().MatchBranch(setResultYieldBlock)) {
// This is a 'yield' in an async enumerator.
if (AnalyzeYieldReturn(block, out var yieldValue, out int state)) {
block.Instructions.Add(new YieldReturn(yieldValue));
Block targetBlock = stateToBlockMap.GetOrDefault(state);
if (targetBlock != null) {
block.Instructions.Add(new Branch(targetBlock));
} else {
block.Instructions.Add(new InvalidBranch("Could not find block for state " + state));
}
} else {
block.Instructions.Add(new InvalidBranch("Could not detect 'yield return'"));
}
}
}
// Skip the state dispatcher and directly jump to the initial state
@ -606,6 +799,7 @@ namespace ICSharpCode.Decompiler.IL.ControlFlow @@ -606,6 +799,7 @@ namespace ICSharpCode.Decompiler.IL.ControlFlow
}
}
bool AnalyzeAwaitBlock(Block block, out ILVariable awaiter, out IField awaiterField, out int state, out int yieldOffset)
{
awaiter = null;
@ -705,6 +899,49 @@ namespace ICSharpCode.Decompiler.IL.ControlFlow @@ -705,6 +899,49 @@ namespace ICSharpCode.Decompiler.IL.ControlFlow
}
return inst;
}
private bool AnalyzeYieldReturn(Block block, out ILInstruction yieldValue, out int newState)
{
yieldValue = default;
newState = default;
Debug.Assert(block.Instructions.Last().MatchBranch(setResultYieldBlock));
// stfld current(ldloc this, ldstr "yieldValue")
// stloc S_45(ldloc this)
// stloc S_46(ldc.i4 -5)
// stloc V_0(ldloc S_46)
// stfld stateField(ldloc S_45, ldloc S_46)
// br setResultYieldBlock
int pos = block.Instructions.Count - 2;
// Immediately before the 'yield return', there should be a state assignment:
if (pos < 0 || !MatchStateAssignment(block.Instructions[pos], out newState))
return false;
pos--;
if (pos >= 0 && block.Instructions[pos].MatchStLoc(cachedStateVar, out var cachedStateNewValue)) {
if (StackSlotValue(cachedStateNewValue).MatchLdcI4(newState)) {
pos--; // OK, ignore V_0 store
} else {
return false;
}
}
while (pos >= 0 && block.Instructions[pos] is StLoc stloc) {
if (stloc.Variable.Kind != VariableKind.StackSlot)
return false;
if (!SemanticHelper.IsPure(stloc.Value.Flags))
return false;
}
if (pos < 0 || !block.Instructions[pos].MatchStFld(out var target, out var field, out yieldValue))
return false;
if (!StackSlotValue(target).MatchLdThis())
return false;
// TODO: check that we are accessing the current field (compare with get_Current)
block.Instructions.RemoveRange(pos, block.Instructions.Count - pos);
return true;
}
#endregion
#region DetectAwaitPattern

2
ICSharpCode.Decompiler/IL/ControlFlow/AwaitInCatchTransform.cs

@ -197,6 +197,8 @@ namespace ICSharpCode.Decompiler.IL.ControlFlow @@ -197,6 +197,8 @@ namespace ICSharpCode.Decompiler.IL.ControlFlow
{
public static void Run(ILFunction function, ILTransformContext context)
{
if (!context.Settings.AwaitInCatchFinally)
return;
HashSet<BlockContainer> changedContainers = new HashSet<BlockContainer>();
// analyze all try-catch statements in the function

10
ICSharpCode.Decompiler/IL/ControlFlow/YieldReturnDecompiler.cs

@ -305,6 +305,16 @@ namespace ICSharpCode.Decompiler.IL.ControlFlow @@ -305,6 +305,16 @@ namespace ICSharpCode.Decompiler.IL.ControlFlow
/// </summary>
bool MatchEnumeratorCreationNewObj(ILInstruction inst)
{
return MatchEnumeratorCreationNewObj(inst, metadata, currentType,
out enumeratorCtor, out enumeratorType);
}
internal static bool MatchEnumeratorCreationNewObj(ILInstruction inst,
MetadataReader metadata, TypeDefinitionHandle currentType,
out MethodDefinitionHandle enumeratorCtor, out TypeDefinitionHandle enumeratorType)
{
enumeratorCtor = default;
enumeratorType = default;
// newobj(CurrentType/...::.ctor, ldc.i4(-2))
if (!(inst is NewObj newObj))
return false;

18
ILSpy/Properties/Resources.Designer.cs generated

@ -556,6 +556,15 @@ namespace ICSharpCode.ILSpy.Properties { @@ -556,6 +556,15 @@ namespace ICSharpCode.ILSpy.Properties {
}
}
/// <summary>
/// Looks up a localized string similar to Decompile async IAsyncEnumerator methods.
/// </summary>
public static string DecompilerSettings_AsyncEnumerator {
get {
return ResourceManager.GetString("DecompilerSettings.AsyncEnumerator", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Decompile ?. and ?[] operators.
/// </summary>
@ -810,6 +819,15 @@ namespace ICSharpCode.ILSpy.Properties { @@ -810,6 +819,15 @@ namespace ICSharpCode.ILSpy.Properties {
}
}
/// <summary>
/// Looks up a localized string similar to Read-only methods.
/// </summary>
public static string DecompilerSettings_ReadOnlyMethods {
get {
return ResourceManager.GetString("DecompilerSettings.ReadOnlyMethods", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Remove dead and side effect free code (use with caution!).
/// </summary>

6
ILSpy/Properties/Resources.resx

@ -769,4 +769,10 @@ Are you sure you want to continue?</value> @@ -769,4 +769,10 @@ Are you sure you want to continue?</value>
<data name="DecompileToNewPanel" xml:space="preserve">
<value>Decompile to new tab</value>
</data>
<data name="DecompilerSettings.AsyncEnumerator" xml:space="preserve">
<value>Decompile async IAsyncEnumerator methods</value>
</data>
<data name="DecompilerSettings.ReadOnlyMethods" xml:space="preserve">
<value>Read-only methods</value>
</data>
</root>
Loading…
Cancel
Save