diff --git a/tests/MSBuildDeviceIntegration/Resources/InterfaceCollectionApp/ExtendedValueProvider.java b/tests/MSBuildDeviceIntegration/Resources/InterfaceCollectionApp/ExtendedValueProvider.java new file mode 100644 index 00000000000..98a4579ec12 --- /dev/null +++ b/tests/MSBuildDeviceIntegration/Resources/InterfaceCollectionApp/ExtendedValueProvider.java @@ -0,0 +1,5 @@ +package net.dot.android.test; + +public interface ExtendedValueProvider extends ValueProvider { + int getOtherValue(); +} diff --git a/tests/MSBuildDeviceIntegration/Resources/InterfaceCollectionApp/InterfaceCollectionFixture.java b/tests/MSBuildDeviceIntegration/Resources/InterfaceCollectionApp/InterfaceCollectionFixture.java new file mode 100644 index 00000000000..b54665e61eb --- /dev/null +++ b/tests/MSBuildDeviceIntegration/Resources/InterfaceCollectionApp/InterfaceCollectionFixture.java @@ -0,0 +1,131 @@ +package net.dot.android.test; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +final class InterfaceCollectionBasePeer implements ValueProvider { + private final int value; + + public InterfaceCollectionBasePeer(int value) { + this.value = value; + } + + @Override + public int getValue() { + return value; + } +} + +final class InterfaceCollectionExtendedPeer implements ExtendedValueProvider { + private final int value; + private final int otherValue; + + public InterfaceCollectionExtendedPeer(int value, int otherValue) { + this.value = value; + this.otherValue = otherValue; + } + + @Override + public int getValue() { + return value; + } + + @Override + public int getOtherValue() { + return otherValue; + } +} + +final class InterfaceCollectionHolder { + private final InterfaceCollectionBasePeer first; + private final InterfaceCollectionBasePeer second; + private final InterfaceCollectionExtendedPeer inheritedFirst; + private final InterfaceCollectionExtendedPeer inheritedSecond; + + public InterfaceCollectionHolder() { + first = new InterfaceCollectionBasePeer(11); + second = new InterfaceCollectionBasePeer(22); + inheritedFirst = new InterfaceCollectionExtendedPeer(33, 333); + inheritedSecond = new InterfaceCollectionExtendedPeer(44, 444); + } + + public List createList() { + List result = new ArrayList<>(); + result.add(first); + result.add(first); + result.add(second); + result.add(null); + return result; + } + + public List createInheritedList() { + List result = new ArrayList<>(); + result.add(inheritedFirst); + result.add(inheritedSecond); + return result; + } + + public Collection createCollection() { + Collection result = new ArrayList<>(); + result.add(first); + result.add(second); + result.add(null); + return result; + } + + public ValueProvider getFirst() { + return first; + } + + public ValueProvider getSecond() { + return second; + } + + public Map createKeyDictionary() { + Map result = new LinkedHashMap<>(); + result.put(first, "first"); + result.put(second, "second"); + result.put(null, "null"); + return result; + } + + public Map createValueDictionary() { + Map result = new LinkedHashMap<>(); + result.put("first", first); + result.put("duplicate", first); + result.put("second", second); + result.put("null", null); + return result; + } + + public Map createInterfaceDictionary() { + Map result = new LinkedHashMap<>(); + result.put(first, second); + result.put(second, first); + result.put(null, null); + return result; + } + + public List roundTripList(List value) { + return value; + } + + public Collection roundTripCollection(Collection value) { + return value; + } + + public Map roundTripKeyDictionary(Map value) { + return value; + } + + public Map roundTripValueDictionary(Map value) { + return value; + } + + public Map roundTripInterfaceDictionary(Map value) { + return value; + } +} diff --git a/tests/MSBuildDeviceIntegration/Resources/InterfaceCollectionApp/MainActivity.cs b/tests/MSBuildDeviceIntegration/Resources/InterfaceCollectionApp/MainActivity.cs new file mode 100644 index 00000000000..db1dce0915b --- /dev/null +++ b/tests/MSBuildDeviceIntegration/Resources/InterfaceCollectionApp/MainActivity.cs @@ -0,0 +1,669 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Reflection; + +using Android.App; +using Android.OS; +using Android.Runtime; +using Android.Util; + +using Java.Interop; + +using Net.Dot.Android.Test; + +namespace ${ROOT_NAMESPACE} +{ + [Register ("${JAVA_PACKAGENAME}.MainActivity"), Activity (Label = "${PROJECT_NAME}", MainLauncher = true)] + public class MainActivity : Activity + { + const string ResultPrefix = "INTERFACE_COLLECTION_RESULT"; + const string ResultToken = "${RESULT_TOKEN}"; + const string Tag = "InterfaceCollections"; + + protected override void OnCreate (Bundle savedInstanceState) + { + base.OnCreate (savedInstanceState); + + int passed = 0; + try { + JavaList_InterfaceElementsPreserveIdentityAndRoundTrip (); + passed++; + JavaList_InheritedInterfaceUsesExplicitInvoker (); + passed++; + JavaCollection_InterfaceElementsSupportOperationsAndRoundTrip (); + passed++; + JavaDictionary_InterfaceKeysSupportOperationsAndRoundTrip (); + passed++; + JavaDictionary_InterfaceValuesPreserveDuplicatesAndRoundTrip (); + passed++; + JavaDictionary_InterfaceKeysAndValuesPreserveIdentityAndRoundTrip (); + passed++; + Log.Info (Tag, $"{ResultPrefix} PASS {passed}/6 {ResultToken}"); + } catch (Exception e) { + Log.Error (Tag, $"{ResultPrefix} FAIL {passed}/6 {ResultToken}: {e}"); + } finally { + Finish (); + } + } + + static void JavaList_InterfaceElementsPreserveIdentityAndRoundTrip () + { + using var holder = new RawInterfaceCollectionHolder (); + var list = holder.CreateList (); + try { + AssertWrapperType (list, typeof (JavaList<>), typeof (IValueProvider)); + AssertEqual (4, list.Count, "list count"); + + var first = list [0]; + var duplicate = list [1]; + var second = list [2]; + + AssertBaseInterfacePeer (first, 11); + AssertBaseInterfacePeer (second, 22); + AssertSame (first, duplicate, "duplicate list reference"); + AssertSame (first, list [0], "repeated list lookup"); + AssertSameJavaObject (first, duplicate); + AssertDistinctJavaObjects (first, second); + AssertNull (list [3], "null list element"); + AssertTrue (list.Contains (first), "list contains first"); + AssertTrue (list.Contains (null), "list contains null"); + + list.Add (second); + AssertEqual (5, list.Count, "list count after add"); + AssertSame (second, list [4], "added list reference"); + AssertSequence ([11, 11, 22, 22], GetValues (list), "list enumeration"); + + var roundTrip = holder.RoundTripList (list); + try { + AssertWrapperType (roundTrip, typeof (JavaList<>), typeof (IValueProvider)); + AssertSameJavaObject (list, roundTrip); + AssertSame (first, roundTrip [0], "round-tripped list element"); + } finally { + DisposeIfDistinct (list, roundTrip); + } + + AssertTrue (list.Remove (first), "remove first list reference"); + AssertTrue (list.Contains (first), "list retains duplicate"); + AssertTrue (list.Remove (first), "remove duplicate list reference"); + AssertFalse (list.Contains (first), "list no longer contains first"); + } finally { + DisposeJavaObject (list); + } + } + + static void JavaList_InheritedInterfaceUsesExplicitInvoker () + { + using var holder = new RawInterfaceCollectionHolder (); + var list = holder.CreateInheritedList (); + try { + AssertWrapperType (list, typeof (JavaList<>), typeof (IExtendedValueProvider)); + AssertEqual (2, list.Count, "inherited list count"); + AssertExtendedInterfacePeer (list [0], 33, 333); + AssertExtendedInterfacePeer (list [1], 44, 444); + } finally { + DisposeJavaObject (list); + } + } + + static void JavaCollection_InterfaceElementsSupportOperationsAndRoundTrip () + { + using var holder = new RawInterfaceCollectionHolder (); + var collection = holder.CreateCollection (); + try { + AssertWrapperType (collection, typeof (JavaCollection<>), typeof (IValueProvider)); + AssertEqual (3, collection.Count, "collection count"); + + var values = new IValueProvider [3]; + collection.CopyTo (values, 0); + var first = values [0]; + var second = values [1]; + AssertBaseInterfacePeer (first, 11); + AssertBaseInterfacePeer (second, 22); + AssertDistinctJavaObjects (first, second); + AssertNull (values [2], "null collection element"); + + collection.Add (first); + AssertEqual (4, collection.Count, "collection count after add"); + AssertTrue (collection.Contains (first), "collection contains first"); + AssertTrue (collection.Contains (null), "collection contains null"); + AssertSame (first, GetElement (collection, 3), "collection enumeration"); + + var roundTrip = holder.RoundTripCollection (collection); + try { + AssertWrapperType (roundTrip, typeof (JavaCollection<>), typeof (IValueProvider)); + AssertSameJavaObject (collection, roundTrip); + AssertSame (first, GetElement (roundTrip, 0), "round-tripped collection element"); + } finally { + DisposeIfDistinct (collection, roundTrip); + } + + collection.Clear (); + AssertEqual (0, collection.Count, "collection count after clear"); + } finally { + DisposeJavaObject (collection); + } + } + + static void JavaDictionary_InterfaceKeysSupportOperationsAndRoundTrip () + { + using var holder = new RawInterfaceCollectionHolder (); + var dictionary = holder.CreateKeyDictionary (); + try { + AssertWrapperType (dictionary, typeof (JavaDictionary<,>), typeof (IValueProvider), typeof (string)); + AssertEqual (3, dictionary.Count, "key dictionary count"); + + var first = holder.GetFirst (); + var second = holder.GetSecond (); + AssertBaseInterfacePeer (first, 11); + AssertBaseInterfacePeer (second, 22); + AssertDistinctJavaObjects (first, second); + AssertTrue (dictionary.ContainsKey (first), "dictionary contains first key"); + AssertTrue (dictionary.ContainsKey (null), "dictionary contains null key"); + AssertEqual ("first", dictionary [first], "first key value"); + AssertEqual ("null", dictionary [null], "null key value"); + AssertSame (first, holder.GetFirst (), "repeated key lookup"); + AssertKeyDictionaryEnumeration (dictionary, first, second); + + var roundTrip = holder.RoundTripKeyDictionary (dictionary); + try { + AssertWrapperType (roundTrip, typeof (JavaDictionary<,>), typeof (IValueProvider), typeof (string)); + AssertSameJavaObject (dictionary, roundTrip); + AssertEqual ("second", roundTrip [second], "round-tripped key value"); + } finally { + DisposeIfDistinct (dictionary, roundTrip); + } + + AssertTrue (dictionary.Remove (first), "remove interface key"); + AssertFalse (dictionary.ContainsKey (first), "removed interface key"); + } finally { + DisposeJavaObject (dictionary); + } + } + + static void JavaDictionary_InterfaceValuesPreserveDuplicatesAndRoundTrip () + { + using var holder = new RawInterfaceCollectionHolder (); + var dictionary = holder.CreateValueDictionary (); + try { + AssertWrapperType (dictionary, typeof (JavaDictionary<,>), typeof (string), typeof (IValueProvider)); + AssertEqual (4, dictionary.Count, "value dictionary count"); + + var first = dictionary ["first"]; + var duplicate = dictionary ["duplicate"]; + var second = dictionary ["second"]; + AssertBaseInterfacePeer (first, 11); + AssertBaseInterfacePeer (second, 22); + AssertSame (first, duplicate, "duplicate dictionary value"); + AssertSame (first, dictionary ["first"], "repeated value lookup"); + AssertDistinctJavaObjects (first, second); + AssertNull (dictionary ["null"], "null dictionary value"); + AssertSequence ([11, 11, 22], GetRawDictionaryValues (dictionary), "dictionary value enumeration"); + + dictionary.Add ("added", second); + AssertSame (second, dictionary ["added"], "added dictionary value"); + + var roundTrip = holder.RoundTripValueDictionary (dictionary); + try { + AssertWrapperType (roundTrip, typeof (JavaDictionary<,>), typeof (string), typeof (IValueProvider)); + AssertSameJavaObject (dictionary, roundTrip); + AssertSame (first, roundTrip ["duplicate"], "round-tripped dictionary value"); + } finally { + DisposeIfDistinct (dictionary, roundTrip); + } + + AssertTrue (dictionary.Remove ("first"), "remove string key"); + AssertFalse (dictionary.ContainsKey ("first"), "removed string key"); + } finally { + DisposeJavaObject (dictionary); + } + } + + static void JavaDictionary_InterfaceKeysAndValuesPreserveIdentityAndRoundTrip () + { + using var holder = new RawInterfaceCollectionHolder (); + var dictionary = holder.CreateInterfaceDictionary (); + try { + AssertWrapperType (dictionary, typeof (JavaDictionary<,>), typeof (IValueProvider), typeof (IValueProvider)); + AssertEqual (3, dictionary.Count, "interface dictionary count"); + + var first = holder.GetFirst (); + var second = holder.GetSecond (); + AssertBaseInterfacePeer (first, 11); + AssertBaseInterfacePeer (second, 22); + AssertSame (second, dictionary [first], "interface dictionary first value"); + AssertSame (first, dictionary [second], "interface dictionary second value"); + AssertNull (dictionary [null], "interface dictionary null value"); + AssertTrue (dictionary.ContainsKey (first), "interface dictionary contains first"); + AssertTrue (ContainsRawPair (dictionary, first, second), "interface dictionary enumeration"); + + var roundTrip = holder.RoundTripInterfaceDictionary (dictionary); + try { + AssertWrapperType (roundTrip, typeof (JavaDictionary<,>), typeof (IValueProvider), typeof (IValueProvider)); + AssertSameJavaObject (dictionary, roundTrip); + AssertSame (second, roundTrip [first], "round-tripped interface dictionary value"); + } finally { + DisposeIfDistinct (dictionary, roundTrip); + } + + AssertTrue (dictionary.Remove (second), "remove interface dictionary key"); + AssertFalse (dictionary.ContainsKey (second), "removed interface dictionary key"); + } finally { + DisposeJavaObject (dictionary); + } + } + + static void AssertBaseInterfacePeer (IValueProvider peer, int expectedValue) + { + AssertNotNull (peer, "base interface peer"); + AssertEqual (expectedValue, peer.Value, "base interface value"); + AssertEqual (typeof (IValueProviderInvoker), peer.GetType (), "base interface invoker"); + AssertFalse (peer is IExtendedValueProvider, "base peer must not implement the derived interface"); + } + + static void AssertExtendedInterfacePeer (IExtendedValueProvider peer, int expectedValue, int expectedOtherValue) + { + AssertNotNull (peer, "extended interface peer"); + AssertEqual (expectedValue, peer.Value, "extended interface value"); + AssertEqual (expectedOtherValue, peer.OtherValue, "extended interface other value"); + AssertEqual (typeof (IExtendedValueProviderInvoker), peer.GetType (), "extended interface invoker"); + } + + static void AssertWrapperType (object wrapper, Type expectedGenericDefinition, params Type [] expectedArguments) + { + var wrapperType = wrapper.GetType (); + AssertTrue (wrapperType.IsGenericType, "wrapper must be generic"); + AssertEqual (expectedGenericDefinition, wrapperType.GetGenericTypeDefinition (), "wrapper generic definition"); + AssertEqual (expectedArguments.Length, wrapperType.GenericTypeArguments.Length, "wrapper generic argument count"); + for (int i = 0; i < expectedArguments.Length; i++) { + AssertEqual (expectedArguments [i], wrapperType.GenericTypeArguments [i], $"wrapper generic argument {i}"); + } + } + + static T ConvertJavaValue (IntPtr handle, JniHandleOwnership transfer = JniHandleOwnership.TransferLocalRef) + { + return (T) InvokeJavaConvertFromJniHandle (typeof (T), handle, transfer); + } + + [DynamicDependency ("FromJniHandle", "Java.Interop.JavaConvert", "Mono.Android")] + static object InvokeJavaConvertFromJniHandle (Type targetType, IntPtr handle, JniHandleOwnership transfer) + { + var javaConvert = typeof (Java.Lang.Object).Assembly.GetType ("Java.Interop.JavaConvert"); + if (javaConvert == null) { + throw new InvalidOperationException ("JavaConvert type was not found."); + } + + var method = javaConvert.GetMethod ( + "FromJniHandle", + BindingFlags.Public | BindingFlags.Static, + binder: null, + types: [typeof (IntPtr), typeof (JniHandleOwnership), typeof (Type)], + modifiers: null); + if (method == null) { + throw new InvalidOperationException ("JavaConvert.FromJniHandle method was not found."); + } + + var value = method.Invoke (null, [handle, transfer, targetType]); + if (value == null) { + throw new InvalidOperationException ($"JavaConvert returned null for target type '{targetType}'."); + } + return value; + } + + static void AssertKeyDictionaryEnumeration ( + IDictionary dictionary, + IValueProvider first, + IValueProvider second) + { + int count = 0; + bool foundFirst = false; + bool foundSecond = false; + bool foundNull = false; + VisitDictionaryEntries (dictionary, (keyHandle, valueHandle) => { + var key = keyHandle == IntPtr.Zero + ? null + : ConvertJavaValue (keyHandle, JniHandleOwnership.DoNotTransfer); + var value = valueHandle == IntPtr.Zero + ? null + : JNIEnv.GetString (valueHandle, JniHandleOwnership.DoNotTransfer); + count++; + if (key == null) { + AssertEqual ("null", value, "raw null key value"); + foundNull = true; + } else if (ReferenceEquals (key, first)) { + AssertEqual ("first", value, "raw first key value"); + foundFirst = true; + } else if (ReferenceEquals (key, second)) { + AssertEqual ("second", value, "raw second key value"); + foundSecond = true; + } else { + throw new InvalidOperationException ($"Unexpected raw dictionary key value '{key.Value}'."); + } + }); + AssertEqual (3, count, "raw key dictionary entry count"); + AssertTrue (foundFirst, "raw first key entry"); + AssertTrue (foundSecond, "raw second key entry"); + AssertTrue (foundNull, "raw null key entry"); + } + + static IValueProvider GetElement (ICollection values, int index) + { + int current = 0; + foreach (var value in values) { + if (current == index) { + return value; + } + current++; + } + throw new InvalidOperationException ($"Collection element {index} was not found."); + } + + static int [] GetValues (IEnumerable peers) + { + var values = new List (); + foreach (var peer in peers) { + if (peer != null) { + values.Add (peer.Value); + } + } + return values.ToArray (); + } + + static int [] GetRawDictionaryValues (IDictionary dictionary) + { + var values = new List (); + bool foundNull = false; + VisitDictionaryEntries (dictionary, (keyHandle, valueHandle) => { + var value = valueHandle == IntPtr.Zero + ? null + : ConvertJavaValue (valueHandle, JniHandleOwnership.DoNotTransfer); + if (value == null) { + foundNull = true; + } else { + values.Add (value.Value); + } + }); + AssertTrue (foundNull, "raw null dictionary value"); + return values.ToArray (); + } + + static bool ContainsRawPair ( + IDictionary dictionary, + IValueProvider expectedKey, + IValueProvider expectedValue) + { + int count = 0; + bool found = false; + VisitDictionaryEntries (dictionary, (keyHandle, valueHandle) => { + var key = keyHandle == IntPtr.Zero + ? null + : ConvertJavaValue (keyHandle, JniHandleOwnership.DoNotTransfer); + var value = valueHandle == IntPtr.Zero + ? null + : ConvertJavaValue (valueHandle, JniHandleOwnership.DoNotTransfer); + count++; + if (ReferenceEquals (key, expectedKey) && ReferenceEquals (value, expectedValue)) { + found = true; + } + }); + AssertEqual (3, count, "raw interface dictionary entry count"); + return found; + } + + static void VisitDictionaryEntries (object dictionary, Action visitor) + { + JniObjectReference mapClass = default; + JniObjectReference setClass = default; + JniObjectReference iteratorClass = default; + JniObjectReference entryClass = default; + IntPtr entrySet = IntPtr.Zero; + IntPtr iterator = IntPtr.Zero; + try { + mapClass = JniEnvironment.Types.FindClass ("java/util/Map"); + setClass = JniEnvironment.Types.FindClass ("java/util/Set"); + iteratorClass = JniEnvironment.Types.FindClass ("java/util/Iterator"); + entryClass = JniEnvironment.Types.FindClass ("java/util/Map$Entry"); + var entrySetMethod = JNIEnv.GetMethodID (mapClass.Handle, "entrySet", "()Ljava/util/Set;"); + var iteratorMethod = JNIEnv.GetMethodID (setClass.Handle, "iterator", "()Ljava/util/Iterator;"); + var hasNextMethod = JNIEnv.GetMethodID (iteratorClass.Handle, "hasNext", "()Z"); + var nextMethod = JNIEnv.GetMethodID (iteratorClass.Handle, "next", "()Ljava/lang/Object;"); + var getKeyMethod = JNIEnv.GetMethodID (entryClass.Handle, "getKey", "()Ljava/lang/Object;"); + var getValueMethod = JNIEnv.GetMethodID (entryClass.Handle, "getValue", "()Ljava/lang/Object;"); + var dictionaryPeer = (IJavaObject) dictionary; + + entrySet = JNIEnv.CallObjectMethod (dictionaryPeer.Handle, entrySetMethod); + iterator = JNIEnv.CallObjectMethod (entrySet, iteratorMethod); + while (JNIEnv.CallBooleanMethod (iterator, hasNextMethod)) { + var entry = JNIEnv.CallObjectMethod (iterator, nextMethod); + var key = IntPtr.Zero; + var value = IntPtr.Zero; + try { + key = JNIEnv.CallObjectMethod (entry, getKeyMethod); + value = JNIEnv.CallObjectMethod (entry, getValueMethod); + visitor (key, value); + } finally { + DeleteLocalRef (ref value); + DeleteLocalRef (ref key); + DeleteLocalRef (ref entry); + } + } + GC.KeepAlive (dictionary); + } finally { + DeleteLocalRef (ref iterator); + DeleteLocalRef (ref entrySet); + JniObjectReference.Dispose (ref entryClass); + JniObjectReference.Dispose (ref iteratorClass); + JniObjectReference.Dispose (ref setClass); + JniObjectReference.Dispose (ref mapClass); + } + } + + static void DeleteLocalRef (ref IntPtr handle) + { + if (handle != IntPtr.Zero) { + JNIEnv.DeleteLocalRef (handle); + handle = IntPtr.Zero; + } + } + + static void AssertSequence (int [] expected, int [] actual, string message) + { + AssertEqual (expected.Length, actual.Length, $"{message} length"); + for (int i = 0; i < expected.Length; i++) { + AssertEqual (expected [i], actual [i], $"{message} element {i}"); + } + } + + static void AssertSameJavaObject (object expected, object actual) + { + var expectedPeer = (IJavaObject) expected; + var actualPeer = (IJavaObject) actual; + AssertTrue ( + JNIEnv.IsSameObject (expectedPeer.Handle, actualPeer.Handle), + $"expected identical Java peers; expected '{expected.GetType ()}' at '{expectedPeer.Handle}', " + + $"actual '{actual.GetType ()}' at '{actualPeer.Handle}'"); + } + + static void AssertDistinctJavaObjects (object first, object second) + { + var firstPeer = (IJavaObject) first; + var secondPeer = (IJavaObject) second; + AssertFalse ( + JNIEnv.IsSameObject (firstPeer.Handle, secondPeer.Handle), + $"expected distinct Java peers; first '{first.GetType ()}' at '{firstPeer.Handle}', " + + $"second '{second.GetType ()}' at '{secondPeer.Handle}'"); + } + + static void AssertTrue (bool value, string message) + { + if (!value) { + throw new InvalidOperationException ($"Assertion failed: {message}."); + } + } + + static void AssertFalse (bool value, string message) + { + AssertTrue (!value, message); + } + + static void AssertNull (object value, string message) + { + if (value != null) { + throw new InvalidOperationException ($"Assertion failed: {message}; expected null, found '{value}'."); + } + } + + static void AssertNotNull (object value, string message) + { + if (value == null) { + throw new InvalidOperationException ($"Assertion failed: {message}; value was null."); + } + } + + static void AssertSame (object expected, object actual, string message) + { + if (!ReferenceEquals (expected, actual)) { + throw new InvalidOperationException ($"Assertion failed: {message}; managed references differ."); + } + } + + static void AssertEqual (T expected, T actual, string message) + { + if (!EqualityComparer.Default.Equals (expected, actual)) { + throw new InvalidOperationException ($"Assertion failed: {message}; expected '{expected}', found '{actual}'."); + } + } + + static void DisposeIfDistinct (object owner, object value) + { + if (!ReferenceEquals (owner, value)) { + DisposeJavaObject (value); + } + } + + static void DisposeJavaObject (object value) + { + if (value is IDisposable disposable) { + disposable.Dispose (); + } + } + + sealed class RawInterfaceCollectionHolder : IDisposable + { + const string CollectionSignature = "()Ljava/util/Collection;"; + const string DictionarySignature = "()Ljava/util/Map;"; + const string JniName = "net/dot/android/test/InterfaceCollectionHolder"; + const string ListSignature = "()Ljava/util/List;"; + const string RoundTripCollectionSignature = "(Ljava/util/Collection;)Ljava/util/Collection;"; + const string RoundTripDictionarySignature = "(Ljava/util/Map;)Ljava/util/Map;"; + const string RoundTripListSignature = "(Ljava/util/List;)Ljava/util/List;"; + + readonly Java.Lang.Object holder; + + public RawInterfaceCollectionHolder () + { + var holderClass = JniEnvironment.Types.FindClass (JniName); + try { + var constructor = JNIEnv.GetMethodID (holderClass.Handle, "", "()V"); + var handle = JNIEnv.NewObject (holderClass.Handle, constructor); + holder = new Java.Lang.Object (handle, JniHandleOwnership.TransferLocalRef); + } finally { + JniObjectReference.Dispose (ref holderClass); + } + } + + public IList CreateList () + { + return ConvertJavaValue> (Call ("createList", ListSignature)); + } + + public IList CreateInheritedList () + { + return ConvertJavaValue> (Call ("createInheritedList", ListSignature)); + } + + public ICollection CreateCollection () + { + return ConvertJavaValue> (Call ("createCollection", CollectionSignature)); + } + + public IValueProvider GetFirst () + { + return ConvertJavaValue (Call ("getFirst", "()Lnet/dot/android/test/ValueProvider;")); + } + + public IValueProvider GetSecond () + { + return ConvertJavaValue (Call ("getSecond", "()Lnet/dot/android/test/ValueProvider;")); + } + + public IDictionary CreateKeyDictionary () + { + return ConvertJavaValue> (Call ("createKeyDictionary", DictionarySignature)); + } + + public IDictionary CreateValueDictionary () + { + return ConvertJavaValue> (Call ("createValueDictionary", DictionarySignature)); + } + + public IDictionary CreateInterfaceDictionary () + { + return ConvertJavaValue> (Call ("createInterfaceDictionary", DictionarySignature)); + } + + public IList RoundTripList (IList value) + { + return ConvertJavaValue> (Call ("roundTripList", RoundTripListSignature, value)); + } + + public ICollection RoundTripCollection (ICollection value) + { + return ConvertJavaValue> (Call ("roundTripCollection", RoundTripCollectionSignature, value)); + } + + public IDictionary RoundTripKeyDictionary (IDictionary value) + { + return ConvertJavaValue> ( + Call ("roundTripKeyDictionary", RoundTripDictionarySignature, value)); + } + + public IDictionary RoundTripValueDictionary (IDictionary value) + { + return ConvertJavaValue> ( + Call ("roundTripValueDictionary", RoundTripDictionarySignature, value)); + } + + public IDictionary RoundTripInterfaceDictionary ( + IDictionary value) + { + return ConvertJavaValue> ( + Call ("roundTripInterfaceDictionary", RoundTripDictionarySignature, value)); + } + + public void Dispose () + { + holder.Dispose (); + } + + IntPtr Call (string methodName, string signature, object value = null) + { + var holderClass = JniEnvironment.Types.GetObjectClass (holder.PeerReference); + try { + var method = JNIEnv.GetMethodID (holderClass.Handle, methodName, signature); + IntPtr handle; + if (value == null) { + handle = JNIEnv.CallObjectMethod (holder.Handle, method); + } else { + var peer = (IJavaObject) value; + handle = JNIEnv.CallObjectMethod (holder.Handle, method, new JValue (peer.Handle)); + GC.KeepAlive (value); + } + return handle; + } finally { + JniObjectReference.Dispose (ref holderClass); + } + } + } + } +} diff --git a/tests/MSBuildDeviceIntegration/Resources/InterfaceCollectionApp/ValueProvider.java b/tests/MSBuildDeviceIntegration/Resources/InterfaceCollectionApp/ValueProvider.java new file mode 100644 index 00000000000..a272243c4ec --- /dev/null +++ b/tests/MSBuildDeviceIntegration/Resources/InterfaceCollectionApp/ValueProvider.java @@ -0,0 +1,5 @@ +package net.dot.android.test; + +public interface ValueProvider { + int getValue(); +} diff --git a/tests/MSBuildDeviceIntegration/Resources/InterfaceCollectionApp/proguard.cfg b/tests/MSBuildDeviceIntegration/Resources/InterfaceCollectionApp/proguard.cfg new file mode 100644 index 00000000000..7ad86bae874 --- /dev/null +++ b/tests/MSBuildDeviceIntegration/Resources/InterfaceCollectionApp/proguard.cfg @@ -0,0 +1,3 @@ +-keep class net.dot.android.test.InterfaceCollectionBasePeer { *; } +-keep class net.dot.android.test.InterfaceCollectionExtendedPeer { *; } +-keep class net.dot.android.test.InterfaceCollectionHolder { *; } diff --git a/tests/MSBuildDeviceIntegration/Tests/InterfaceCollectionTests.cs b/tests/MSBuildDeviceIntegration/Tests/InterfaceCollectionTests.cs new file mode 100644 index 00000000000..b9e679909c8 --- /dev/null +++ b/tests/MSBuildDeviceIntegration/Tests/InterfaceCollectionTests.cs @@ -0,0 +1,476 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Text; +using System.Xml; + +using NUnit.Framework; + +using Xamarin.Android.Tasks; +using Xamarin.Android.Tools; +using Xamarin.ProjectTools; + +namespace Xamarin.Android.Build.Tests +{ + [TestFixture] + [Category ("UsesDevice")] + public class InterfaceCollectionTests : DeviceTest + { + const string DgmlNamespace = "http://schemas.microsoft.com/vs/2009/dgml"; + const string ResultPrefix = "INTERFACE_COLLECTION_RESULT"; + + [TestCase ("llvm-ir", AndroidRuntime.CoreCLR)] + [TestCase ("trimmable", AndroidRuntime.CoreCLR)] + [TestCase ("trimmable", AndroidRuntime.NativeAOT)] + public void InterfaceValuedJavaCollections (string typemapImplementation, AndroidRuntime runtime) + { + var suffix = $"interfacecollections{typemapImplementation.Replace ("-", "")}{runtime}".ToLowerInvariant (); + var proj = new XamarinAndroidApplicationProject (packageName: PackageUtils.MakePackageName (runtime, suffix)) { + IsRelease = true, + }; + proj.SetRuntime (runtime); + proj.SetRuntimeIdentifiers ([DeviceAbi]); + proj.SetProperty ("AndroidTypeMapImplementation", typemapImplementation); + proj.SetProperty ("AndroidSdkDirectory", AndroidSdkResolver.GetAndroidSdkPath ()); + var javaSdkDirectory = AndroidSdkResolver.GetJavaSdkPath (); + proj.SetProperty ("JavaSdkDirectory", javaSdkDirectory); + proj.SetProperty ("JavaCPath", Path.Combine (javaSdkDirectory, "bin", "javac")); + proj.SetProperty ("JarPath", Path.Combine (javaSdkDirectory, "bin", "jar")); + proj.SetDefaultTargetDevice (); + var resultToken = Guid.NewGuid ().ToString ("N"); + proj.MainActivity = proj.ProcessSourceTemplate ( + ReadFixture ("MainActivity.cs").Replace ("${RESULT_TOKEN}", resultToken, StringComparison.Ordinal)); + proj.AndroidJavaSources.Add (CreateJavaSource ("ValueProvider.java", bind: true)); + proj.AndroidJavaSources.Add (CreateJavaSource ("ExtendedValueProvider.java", bind: true)); + proj.AndroidJavaSources.Add (CreateJavaSource ("InterfaceCollectionFixture.java", bind: false)); + proj.OtherBuildItems.Add (new AndroidItem.ProguardConfiguration ("proguard.cfg") { + TextContent = () => ReadFixture ("proguard.cfg"), + }); + + var testDirectory = Path.Combine ("temp", $"{nameof (InterfaceValuedJavaCollections)}-{typemapImplementation}-{runtime}"); + using var builder = CreateApkBuilder (testDirectory); + try { + Assert.IsTrue (builder.Install (proj), "The focused interface-collection app should install."); + AssertGeneratedBindingsAreIsolated (builder, proj); + + ClearAdbLogcat (); + var logcatPath = Path.Combine (Root, builder.ProjectDirectory, "interface-collections-logcat.log"); + StartActivityAndAssert (proj); + string logcatOutput = ""; + string resultLine = ""; + WaitFor (TimeSpan.FromSeconds (ActivityStartTimeoutInSeconds), () => { + logcatOutput = RunAdbCommand ("logcat -d"); + resultLine = FindResultLine (logcatOutput, resultToken); + return resultLine.Length > 0; + }, intervalInMS: 250); + File.WriteAllText (logcatPath, logcatOutput); + Assert.IsNotEmpty (resultLine, $"The focused app did not report a result. See '{logcatPath}'."); + StringAssert.Contains ($"{ResultPrefix} PASS 6/6", resultLine); + + if (runtime == AndroidRuntime.NativeAOT) { + var projectDirectory = Path.Combine (Root, builder.ProjectDirectory); + var dgmlFiles = Directory.GetFiles (projectDirectory, $"{proj.ProjectName}.scan.dgml.xml", SearchOption.AllDirectories); + Assert.AreEqual (1, dgmlFiles.Length, "The focused NativeAOT app should produce one scan dependency graph."); + AssertCanonicalWrapperRooting (dgmlFiles [0]); + TestContext.Out.WriteLine ($"Focused NativeAOT dependency graph: {dgmlFiles [0]}"); + } + } finally { + RunAdbCommand ($"uninstall {proj.PackageName}"); + } + } + + static string FindResultLine (string logcatOutput, string resultToken) + { + foreach (var line in logcatOutput.Split (['\r', '\n'], StringSplitOptions.RemoveEmptyEntries)) { + if (line.Contains (ResultPrefix, StringComparison.Ordinal) && + line.Contains (resultToken, StringComparison.Ordinal)) { + return line; + } + } + return ""; + } + + static AndroidItem.AndroidJavaSource CreateJavaSource (string fileName, bool bind) + { + return new AndroidItem.AndroidJavaSource (Path.Combine ("java", "net", "dot", "android", "test", fileName)) { + Encoding = Encoding.ASCII, + TextContent = () => ReadFixture (fileName), + Metadata = { + { "Bind", bind.ToString () }, + }, + }; + } + + void AssertGeneratedBindingsAreIsolated (ProjectBuilder builder, XamarinAndroidApplicationProject proj) + { + var projectDirectory = Path.Combine (Root, builder.ProjectDirectory); + var generatedSourceDirectory = Path.Combine (projectDirectory, proj.IntermediateOutputPath, "generated", "src"); + FileAssert.Exists (Path.Combine (generatedSourceDirectory, "Net.Dot.Android.Test.IValueProvider.cs")); + FileAssert.Exists (Path.Combine (generatedSourceDirectory, "Net.Dot.Android.Test.IExtendedValueProvider.cs")); + Assert.IsEmpty ( + Directory.GetFiles (generatedSourceDirectory, "*InterfaceCollection*.cs", SearchOption.TopDirectoryOnly), + "The raw JNI holder and concrete peers must not produce managed bindings that can root closed collection wrappers."); + } + + static void AssertCanonicalWrapperRooting (string dgmlFile) + { + var chains = new [] { + new RootingChain ( + "JavaList", + "SafeJavaCollectionFactory__CreateReferenceListFromJniHandle, " + + "Type metadata: [Java.Interop]Java.Interop.IJavaPeerable)", + "Mono_Android_Android_Runtime_JavaList_1 constructed", + "__GenericDict_Mono_Android_Android_Runtime_JavaList_1", + "(__GenericDict_Mono_Android_Android_Runtime_JavaList_1, " + + "Mono_Android_Android_Runtime_JavaList_1___ctor_0)", + "Mono_Android_Android_Runtime_JavaList_1___ctor_0", + "JavaList`1..ctor(native int,JniHandleOwnership)"), + new RootingChain ( + "JavaCollection", + "SafeJavaCollectionFactory__CreateReferenceCollectionFromJniHandle, " + + "Type metadata: [Java.Interop]Java.Interop.IJavaPeerable)", + "Mono_Android_Android_Runtime_JavaCollection_1 constructed", + "__GenericDict_Mono_Android_Android_Runtime_JavaCollection_1", + "(__GenericDict_Mono_Android_Android_Runtime_JavaCollection_1, " + + "Mono_Android_Android_Runtime_JavaCollection_1___ctor)", + "Mono_Android_Android_Runtime_JavaCollection_1___ctor", + "JavaCollection`1..ctor(native int,JniHandleOwnership)"), + new RootingChain ( + "JavaDictionary", + "SafeJavaCollectionFactory__CreateReferenceDictionaryFromJniHandle, " + + "Type metadata: [Java.Interop]Java.Interop.IJavaPeerable)", + "Mono_Android_Android_Runtime_JavaDictionary_2 constructed", + "__GenericDict_Mono_Android_Android_Runtime_JavaDictionary_2", + "(__GenericDict_Mono_Android_Android_Runtime_JavaDictionary_2, " + + "Mono_Android_Android_Runtime_JavaDictionary_2___ctor_0)", + "Mono_Android_Android_Runtime_JavaDictionary_2___ctor_0", + "JavaDictionary`2..ctor(native int,JniHandleOwnership)"), + }; + var duplicateNodeIds = new List (); + var missingNodeIds = new List (); + var nodeIds = new HashSet (StringComparer.Ordinal); + var unexpectedCanonicalRoots = new List (); + + using (var reader = CreateDgmlReader (dgmlFile)) { + while (reader.Read ()) { + if (reader.NodeType != XmlNodeType.Element || + reader.LocalName != "Node" || + reader.NamespaceURI != DgmlNamespace) { + continue; + } + var id = reader.GetAttribute ("Id") ?? ""; + var label = reader.GetAttribute ("Label") ?? ""; + if (id.Length == 0) { + missingNodeIds.Add (label); + } else if (!nodeIds.Add (id)) { + duplicateNodeIds.Add ($"Id=\"{id}\" Label=\"{label}\""); + } + foreach (var chain in chains) { + chain.ObserveNode (id, label); + } + if (IsUnexpectedCanonicalReferenceConstructor (label)) { + unexpectedCanonicalRoots.Add (label); + } + } + } + + using (var reader = CreateDgmlReader (dgmlFile)) { + while (reader.Read ()) { + if (reader.NodeType != XmlNodeType.Element || + reader.LocalName != "Link" || + reader.NamespaceURI != DgmlNamespace) { + continue; + } + var source = reader.GetAttribute ("Source") ?? ""; + var target = reader.GetAttribute ("Target") ?? ""; + var reason = reader.GetAttribute ("Reason") ?? ""; + foreach (var chain in chains) { + chain.ObserveLink (source, target, reason); + } + } + } + + Assert.IsEmpty (missingNodeIds, "The NativeAOT dependency graph contained nodes without IDs."); + Assert.IsEmpty (duplicateNodeIds, "The NativeAOT dependency graph contained duplicate node IDs."); + Assert.IsEmpty ( + unexpectedCanonicalRoots, + "Only SafeJavaCollectionFactory's IJavaPeerable instantiations should root the reference-wrapper canonical constructors."); + foreach (var chain in chains) { + chain.AssertComplete (); + TestContext.Out.WriteLine ($"{chain.Name} canonical constructor rooted through SafeJavaCollectionFactory."); + } + } + + static XmlReader CreateDgmlReader (string dgmlFile) + { + return XmlReader.Create (dgmlFile, new XmlReaderSettings { + DtdProcessing = DtdProcessing.Prohibit, + XmlResolver = null, + }); + } + + static bool IsUnexpectedCanonicalReferenceConstructor (string label) + { + if (!label.Contains ("..ctor(native int,JniHandleOwnership) backed by ", StringComparison.Ordinal)) { + return false; + } + bool usesReferenceCanonicalCode = + label.Contains ("JavaList_1___ctor_0", StringComparison.Ordinal) || + label.Contains ("JavaCollection_1___ctor", StringComparison.Ordinal) || + label.Contains ("JavaDictionary_2___ctor_0", StringComparison.Ordinal); + if (!usesReferenceCanonicalCode) { + return false; + } + bool isExpectedRoot = + label == "[Mono.Android]Android.Runtime.JavaList`1..ctor(native int,JniHandleOwnership) " + + "backed by Mono_Android_Android_Runtime_JavaList_1___ctor_0" || + label == "[Mono.Android]Android.Runtime.JavaList`1..ctor(native int,JniHandleOwnership) " + + "backed by Mono_Android_Android_Runtime_JavaList_1___ctor_0" || + label == "[Mono.Android]Android.Runtime.JavaCollection`1..ctor(native int,JniHandleOwnership) " + + "backed by Mono_Android_Android_Runtime_JavaCollection_1___ctor" || + label == "[Mono.Android]Android.Runtime.JavaCollection`1..ctor(native int,JniHandleOwnership) " + + "backed by Mono_Android_Android_Runtime_JavaCollection_1___ctor" || + label == "[Mono.Android]Android.Runtime.JavaDictionary`2" + + "..ctor(native int,JniHandleOwnership) backed by " + + "Mono_Android_Android_Runtime_JavaDictionary_2___ctor_0" || + label == "[Mono.Android]Android.Runtime.JavaDictionary`2" + + "..ctor(native int,JniHandleOwnership) backed by " + + "Mono_Android_Android_Runtime_JavaDictionary_2___ctor_0"; + return !isExpectedRoot; + } + + static string ReadFixture (string fileName) + { + return File.ReadAllText ( + Path.Combine ( + XABuildPaths.TopDirectory, + "tests", + "MSBuildDeviceIntegration", + "Resources", + "InterfaceCollectionApp", + fileName)); + } + + sealed class RootingChain + { + readonly string constructorPattern; + readonly string canonicalConstructorPattern; + readonly string constructedTypePattern; + readonly string genericDictionaryPattern; + readonly string genericDictionaryDependencyPattern; + readonly string sourcePattern; + readonly List ambiguousNodeMatches = new (); + readonly HashSet observedNodeRoles = new (StringComparer.Ordinal); + readonly List unexpectedIncomingLinks = new (); + + string canonicalConstructorId = ""; + string constructedTypeId = ""; + string constructorId = ""; + string genericDictionaryId = ""; + string genericDictionaryDependencyId = ""; + string sourceId = ""; + bool canonicalConstructorToDependency; + bool constructedTypeToGenericDictionary; + bool genericDictionaryToDependency; + bool genericDictionaryToConstructor; + bool sourceToConstructedType; + + public RootingChain ( + string name, + string sourcePattern, + string constructedTypePattern, + string genericDictionaryPattern, + string genericDictionaryDependencyPattern, + string canonicalConstructorPattern, + string constructorPattern) + { + Name = name; + this.sourcePattern = sourcePattern; + this.constructedTypePattern = constructedTypePattern; + this.genericDictionaryPattern = genericDictionaryPattern; + this.genericDictionaryDependencyPattern = genericDictionaryDependencyPattern; + this.canonicalConstructorPattern = canonicalConstructorPattern; + this.constructorPattern = constructorPattern; + } + + public string Name { get; } + + public void ObserveNode (string id, string label) + { + int matchedRoles = 0; + matchedRoles += ObserveNode ( + label == $"(Mono_Android_Java_Interop_{sourcePattern}", + id, + label, + "SafeJavaCollectionFactory source", + ref sourceId) ? 1 : 0; + matchedRoles += ObserveNode ( + IsConstructedTypeLabel (label, constructedTypePattern), + id, + label, + "IJavaPeerable constructed type", + ref constructedTypeId) ? 1 : 0; + matchedRoles += ObserveNode ( + label == genericDictionaryPattern, + id, + label, + "IJavaPeerable generic dictionary", + ref genericDictionaryId) ? 1 : 0; + matchedRoles += ObserveNode ( + label == genericDictionaryDependencyPattern, + id, + label, + "IJavaPeerable constructor dictionary dependency", + ref genericDictionaryDependencyId) ? 1 : 0; + matchedRoles += ObserveNode ( + label == canonicalConstructorPattern, + id, + label, + "canonical compiled constructor", + ref canonicalConstructorId) ? 1 : 0; + matchedRoles += ObserveNode ( + label == $"[Mono.Android]Android.Runtime.{constructorPattern} backed by {canonicalConstructorPattern}", + id, + label, + "IJavaPeerable activation constructor", + ref constructorId) ? 1 : 0; + if (matchedRoles > 1) { + ambiguousNodeMatches.Add ($"multiple roles: Id=\"{id}\" Label=\"{label}\""); + } + } + + public void ObserveLink (string source, string target, string reason) + { + sourceToConstructedType |= IsLink (source, target, reason, sourceId, constructedTypeId, "newobj"); + constructedTypeToGenericDictionary |= IsLink (source, target, reason, constructedTypeId, genericDictionaryId, "reloc"); + genericDictionaryToDependency |= IsLink ( + source, + target, + reason, + genericDictionaryId, + genericDictionaryDependencyId, + "Primary"); + canonicalConstructorToDependency |= IsLink ( + source, + target, + reason, + canonicalConstructorId, + genericDictionaryDependencyId, + "Secondary"); + genericDictionaryToConstructor |= IsLink ( + source, + target, + reason, + genericDictionaryDependencyId, + constructorId, + "Generic dictionary dependency"); + + RejectUnexpectedIncoming (source, target, reason, constructedTypeId, sourceId, "newobj"); + RejectUnexpectedIncoming (source, target, reason, genericDictionaryId, constructedTypeId, "reloc"); + if (IsIncomingLink (target, genericDictionaryDependencyId) && + !IsLink (source, target, reason, genericDictionaryId, genericDictionaryDependencyId, "Primary") && + !IsLink (source, target, reason, canonicalConstructorId, genericDictionaryDependencyId, "Secondary")) { + unexpectedIncomingLinks.Add (FormatLink (source, target, reason)); + } + RejectUnexpectedIncoming ( + source, + target, + reason, + constructorId, + genericDictionaryDependencyId, + "Generic dictionary dependency"); + } + + public void AssertComplete () + { + Assert.IsEmpty (ambiguousNodeMatches, $"{Name} canonical constructor path had ambiguous node matches."); + Assert.IsNotEmpty (sourceId, $"{Name} SafeJavaCollectionFactory source node was not found."); + Assert.IsNotEmpty (constructedTypeId, $"{Name} IJavaPeerable constructed-type node was not found."); + Assert.IsNotEmpty (genericDictionaryId, $"{Name} IJavaPeerable generic dictionary node was not found."); + Assert.IsNotEmpty (genericDictionaryDependencyId, $"{Name} IJavaPeerable constructor dictionary dependency was not found."); + Assert.IsNotEmpty (canonicalConstructorId, $"{Name} canonical compiled constructor node was not found."); + Assert.IsNotEmpty (constructorId, $"{Name} IJavaPeerable activation constructor node was not found."); + Assert.IsTrue (sourceToConstructedType, $"{Name} SafeJavaCollectionFactory newobj dependency was not found."); + Assert.IsTrue (constructedTypeToGenericDictionary, $"{Name} constructed-type relocation dependency was not found."); + Assert.IsTrue (genericDictionaryToDependency, $"{Name} generic dictionary primary dependency was not found."); + Assert.IsTrue (canonicalConstructorToDependency, $"{Name} canonical constructor secondary dependency was not found."); + Assert.IsTrue (genericDictionaryToConstructor, $"{Name} generic dictionary constructor dependency was not found."); + Assert.IsEmpty (unexpectedIncomingLinks, $"{Name} canonical constructor path had an unexpected incoming dependency."); + } + + bool ObserveNode (bool matches, string id, string label, string role, ref string observedId) + { + if (!matches) { + return false; + } + if (!observedNodeRoles.Add (role)) { + ambiguousNodeMatches.Add ($"{role}: Id=\"{id}\" Label=\"{label}\""); + return true; + } + observedId = id; + return true; + } + + void RejectUnexpectedIncoming ( + string source, + string target, + string reason, + string expectedTarget, + string expectedSource, + string expectedReason) + { + if (IsIncomingLink (target, expectedTarget) && + !IsLink (source, target, reason, expectedSource, expectedTarget, expectedReason)) { + unexpectedIncomingLinks.Add (FormatLink (source, target, reason)); + } + } + + static bool IsIncomingLink (string actualTarget, string expectedTarget) + { + return expectedTarget.Length > 0 && actualTarget == expectedTarget; + } + + static bool IsLink ( + string actualSource, + string actualTarget, + string actualReason, + string expectedSource, + string expectedTarget, + string expectedReason) + { + return expectedSource.Length > 0 && + expectedTarget.Length > 0 && + actualSource == expectedSource && + actualTarget == expectedTarget && + actualReason == expectedReason; + } + + static string FormatLink (string source, string target, string reason) + { + return $"Source=\"{source}\" Target=\"{target}\" Reason=\"{reason}\""; + } + + static bool IsConstructedTypeLabel (string label, string constructedTypePattern) + { + if (!label.EndsWith (constructedTypePattern, StringComparison.Ordinal)) { + return false; + } + + int prefixLength = label.Length - constructedTypePattern.Length; + if (prefixLength <= "_ZTV".Length || + !label.StartsWith ("_ZTV", StringComparison.Ordinal)) { + return false; + } + for (int i = "_ZTV".Length; i < prefixLength; i++) { + if (label [i] < '0' || label [i] > '9') { + return false; + } + } + return true; + } + } + } +}