Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
Expand Up @@ -35,3 +35,9 @@ Makefile eol=lf
*.wixproj eol=crlf
*.wxs eol=crlf
*.rtf eol=crlf

# Gradle wrapper must stay LF on all platforms (executed under Bash on Unix)
gradlew eol=lf
*.properties eol=lf
*.kt eol=lf
*.kts eol=lf
9 changes: 9 additions & 0 deletions src/Java.Interop.Localization/Resources.Designer.cs

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions src/Java.Interop.Localization/Resources.resx
Original file line number Diff line number Diff line change
Expand Up @@ -308,6 +308,10 @@ The following terms should not be translated: Metadata.xml, path.</comment>
<value>For type '{0}', base interface '{1}' is invalid.</value>
<comment>{0}, {1} - .NET types.</comment>
</data>
<data name="Generator_BG8C02" xml:space="preserve">
<value>For type '{0}', the Kotlin name-mangled method '{1}' (originally '{2}') has multiple hash-suffixed siblings that erase to the same C# signature. Only the first will be emitted; remove the duplicate via Metadata.xml to suppress this warning.</value>
Comment thread
jonathanpeppers marked this conversation as resolved.
<comment>{0} - .NET type. {1} - C# method name. {2} - Original (mangled) JVM method name.</comment>
</data>
<data name="JavaCallableWrappers_XA4200" xml:space="preserve">
<value>Cannot generate Java wrapper for type '{0}'. Only 'class' types are supported.</value>
<comment>{0} - Java type.
Expand Down
1 change: 1 addition & 0 deletions src/Java.Interop.Tools.Generator/Utilities/Report.cs
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ public LocalizedMessage (int code, string value)
public static LocalizedMessage WarningUnknownGenericConstraint => new LocalizedMessage (0x8B00, Localization.Resources.Generator_BG8B00);
public static LocalizedMessage WarningBaseInterfaceNotFound => new LocalizedMessage (0x8C00, Localization.Resources.Generator_BG8C00);
public static LocalizedMessage WarningBaseInterfaceInvalid => new LocalizedMessage (0x8C01, Localization.Resources.Generator_BG8C01);
public static LocalizedMessage WarningKotlinNameMangledCollision => new LocalizedMessage (0x8C02, Localization.Resources.Generator_BG8C02);

public static void LogCodedErrorAndExit (LocalizedMessage message, params string [] args)
=> LogCodedErrorAndExit (message, null, null, args);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
using System;
using System.Linq;
using NUnit.Framework;
using Xamarin.Android.Tools.Bytecode;

namespace Xamarin.Android.Tools.BytecodeTests
{
// Exercises the real Kotlin bytecode produced by the Gradle fixture under
// kotlin-gradle/ to confirm that the JVM-level mangling we expect (and that
// the generator's KotlinFixups must now de-collide) is actually what kotlinc
// emits for @JvmInline value-class parameters. See dotnet/java-interop#1431.
[TestFixture]
public class KotlinInlineClassCollisionTests : ClassFileFixture
{
[Test]
public void Widgets_HasCollidingHashMangledSiblings ()
{
var klass = LoadClassFile ("Widgets.class");

// Kotlin emits one mangled method per inline-class overload:
// tint-<hash>(J)V for MyColor (ULong-backed)
// tint-<hash>(J)V for MyAlpha (ULong-backed) — collides with MyColor
// tint-<hash>(F)V for MyDp (Float-backed) — unique
var tints = klass.Methods
.Where (m => m.Name.StartsWith ("tint-", StringComparison.Ordinal))
.ToList ();

Assert.AreEqual (3, tints.Count, "Expected three `tint-<hash>` overloads from the Gradle fixture.");

var longTints = tints.Where (m => m.Descriptor == "(J)V").ToList ();
Assert.AreEqual (2, longTints.Count,
"Expected two `tint-<hash>(J)V` siblings (MyColor + MyAlpha) — this is the multi-sibling collision case from dotnet/java-interop#1431.");

Assert.AreEqual (1, tints.Count (m => m.Descriptor == "(F)V"),
"Expected one unique `tint-<hash>(F)V` (MyDp) that should survive deduplication.");
}

[Test]
public void Widgets_HasNonCollidingHashMangledOverloads ()
{
var klass = LoadClassFile ("Widgets.class");

var pads = klass.Methods
.Where (m => m.Name.StartsWith ("pad-", StringComparison.Ordinal))
.ToList ();

Assert.AreEqual (2, pads.Count);
CollectionAssert.AreEquivalent (
new [] { "(F)F", "(FF)F" },
pads.Select (m => m.Descriptor).ToArray (),
"`pad` overloads have distinct JVM signatures and should both survive after rename.");
}

[Test]
public void InlineClasses_AreEmittedAsValueClasses ()
{
// Sanity check that @JvmInline really produced a JvmInline annotation on
// the inline-class type — this is what step (2) of #1431 will key on.
var myColor = LoadClassFile ("MyColor.class");

var annotations = myColor.Attributes
.OfType<RuntimeVisibleAnnotationsAttribute> ()
.SelectMany (a => a.Annotations)
.Select (a => a.Type)
.ToList ();

Assert.Contains ("Lkotlin/jvm/JvmInline;", annotations);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,13 @@

<ItemGroup>
<EmbeddedResource Include="Resources\*" />
<EmbeddedResource Include="kotlin*\**\*.class" />
<EmbeddedResource Include="kotlin\**\*.class" />
<EmbeddedResource Include="kotlin-ThirdParty\**\*.class" />

<EmbeddedResource Include="kotlin-gradle\classes\xat\bytecode\tests\Widgets.class" LogicalName="Xamarin.Android.Tools.BytecodeTests.kotlin-gradle.Widgets.class" />
<EmbeddedResource Include="kotlin-gradle\classes\xat\bytecode\tests\MyColor.class" LogicalName="Xamarin.Android.Tools.BytecodeTests.kotlin-gradle.MyColor.class" />
<EmbeddedResource Include="kotlin-gradle\classes\xat\bytecode\tests\MyAlpha.class" LogicalName="Xamarin.Android.Tools.BytecodeTests.kotlin-gradle.MyAlpha.class" />
<EmbeddedResource Include="kotlin-gradle\classes\xat\bytecode\tests\MyDp.class" LogicalName="Xamarin.Android.Tools.BytecodeTests.kotlin-gradle.MyDp.class" />

<EmbeddedResource Include="$(IntermediateOutputPath)classes\com\xamarin\NotNullClass.class" />
<EmbeddedResource Include="$(IntermediateOutputPath)classes\com\xamarin\IJavaInterface.class" />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,12 @@
<TestJarNoParameters Include="java/**/*NoParameters.java" />
<TestJar Include="java\**\*.java" Exclude="@(TestJarNoParameters);java\android\annotation\NonNull.java;" />
<TestKotlinJar Include="kotlin\**\*.kt" />
<TestKotlinGradleSource Include="kotlin-gradle\src\**\*.kt;kotlin-gradle\*.gradle.kts" />
<TestKotlinGradleOutput Include="kotlin-gradle\classes\xat\bytecode\tests\Widgets.class" />
<TestKotlinGradleOutput Include="kotlin-gradle\classes\xat\bytecode\tests\MyColor.class" />
<TestKotlinGradleOutput Include="kotlin-gradle\classes\xat\bytecode\tests\MyAlpha.class" />
<TestKotlinGradleOutput Include="kotlin-gradle\classes\xat\bytecode\tests\MyDp.class" />
<TestKotlinGradleOutput Include="kotlin-gradle\classes\xat\bytecode\tests\InlineClassCollisionsKt.class" />
</ItemGroup>

<ItemGroup>
Expand Down Expand Up @@ -34,6 +40,25 @@
<Exec Command="&quot;$(KotlinCPath)&quot; @(TestKotlinJar->'%(Identity)', ' ') -d &quot;kotlin&quot;" />
</Target>

<!--
Build the real Kotlin/Gradle fixture in kotlin-gradle\ using the shared
Gradle wrapper from build-tools/gradle. Unlike BuildKotlinClasses above,
this is run unconditionally (a JDK is already required for BuildClasses),
so the .class files do not need to be committed. The wrapper downloads
Gradle + Kotlin on first run.
-->
<PropertyGroup>
<_KotlinGradleProjectDir>$(MSBuildThisFileDirectory)kotlin-gradle</_KotlinGradleProjectDir>
</PropertyGroup>

<Target Name="BuildKotlinGradleProject"
BeforeTargets="BeforeCompile"
Inputs="@(TestKotlinGradleSource)"
Outputs="@(TestKotlinGradleOutput)">
<Exec Command="&quot;$(GradleWPath)&quot; $(GradleArgs) -p &quot;$(_KotlinGradleProjectDir)&quot; classes"
EnvironmentVariables="JAVA_HOME=$(JavaSdkDirectory);APP_HOME=$(GradleHome)" />
</Target>

<Target Name="BuildJar"
AfterTargets="BuildClasses"
Inputs="@(_BuildClassOutputs)"
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
# Gradle build outputs - rebuilt from source on every test build.
.gradle/
build/
classes/
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
plugins {
kotlin("jvm") version "2.0.21"
}

repositories {
mavenCentral()
}

// Don't pin a jvmToolchain -- it would force Gradle to auto-provision a
// matching JDK and fail in CI environments without download repositories
// configured. Use whatever JDK the caller already set in JAVA_HOME (the
// .NET build forwards $(JavaSdkDirectory) for consistency with the rest
// of the repo). Kotlin 2.0.21 targets JVM 11 by default, which is fine
// for the bytecode the tests inspect.

// Emit compiled classes into a stable, predictable location so the
// .NET test harness can load them via ClassFileFixture without needing
// to know the Gradle build directory layout.
tasks.named<org.jetbrains.kotlin.gradle.tasks.KotlinCompile>("compileKotlin") {
destinationDirectory.set(file("$rootDir/classes"))
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
rootProject.name = "kotlin-inline-class-fixtures"
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
@file:JvmName("InlineClassCollisionsKt")

package xat.bytecode.tests

// Two distinct Kotlin inline classes that erase to the same JVM primitive (long).
// Both `tint(MyColor)` and `tint(MyAlpha)` mangle to `tint-<hash>(J)V`, so they
// collide once class-parse drops the hash suffix. This is the exact scenario
// that Jetpack Compose triggers with Color/TextUnit/etc. and is the case
// step (1) of dotnet/java-interop#1431 must handle.
@JvmInline
value class MyColor(val value: ULong)

@JvmInline
value class MyAlpha(val value: ULong)

// A second inline class with a different backing primitive, so we can verify
// that *non*-colliding hash siblings still survive.
@JvmInline
value class MyDp(val value: Float)

object Widgets {

// Colliding pair: both erase to `tint-XXXXXXX(J)V`.
fun tint(color: MyColor) { /* no-op */ }
fun tint(alpha: MyAlpha) { /* no-op */ }

// Distinct hash-mangled sibling of the same source name — should survive
// alongside one of the `tint(long)` overloads.
fun tint(dp: MyDp) { /* no-op */ }

// A non-colliding pair: different arity, both hash-mangled.
fun pad(dp: MyDp): MyDp = dp
fun pad(dp1: MyDp, dp2: MyDp): MyDp = dp1
}
125 changes: 125 additions & 0 deletions tests/generator-Tests/Unit-Tests/KotlinFixupsTests.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Xml.Linq;
using Java.Interop.Tools.Generator;
using Java.Interop.Tools.Generator.Transformation;
using MonoDroid.Generation;
using NUnit.Framework;
Expand Down Expand Up @@ -35,5 +38,127 @@ public void CreateMethod_EnsureKotlinHashcodeFix ()
Assert.IsTrue (klass.Methods [0].IsFinal);
Assert.IsFalse (klass.Methods [0].IsVirtual);
}

[Test, NonParallelizable]
public void CollidingHashSiblings_AreDeduplicated ()
{
// Two Kotlin hash-mangled siblings that erase to the same C# signature
// (one `long` parameter). After the rename to `Add` both would collide,
// so we keep only the first.
var xml = XDocument.Parse (@"<package name='com.example.test' jni-name='com/example/test'>
<class name='test'>
<method name='add-AAAAAAA' final='false'>
<parameter name='p0' type='long' jni-type='J' />
</method>
<method name='add-BBBBBBB' final='false'>
<parameter name='p0' type='long' jni-type='J' />
</method>
</class>
</package>");
var klass = XmlApiImporter.CreateClass (xml.Root, xml.Root.Element ("class"), new CodeGenerationOptions ());

using var warnings = CaptureWarnings ();
KotlinFixups.Fixup (new [] { (GenBase) klass }.ToList ());

Assert.AreEqual (1, klass.Methods.Count, "Duplicate hash-mangled sibling should have been removed.");
Assert.AreEqual ("Add", klass.Methods [0].Name);
Assert.AreEqual ("add-AAAAAAA", klass.Methods [0].JavaName, "The first hash-mangled sibling in source order should survive.");
Assert.IsTrue (warnings.Messages.Any (m => m.Contains ("BG8C02")), "Expected BG8C02 warning, got: " + string.Join (Environment.NewLine, warnings.Messages));
}

[Test]
public void NonCollidingHashSiblings_AreBothKept ()
{
// Two siblings with distinct parameter lists: both should rename to `Add`
// and survive as overloads.
var xml = XDocument.Parse (@"<package name='com.example.test' jni-name='com/example/test'>
<class name='test'>
<method name='add-AAAAAAA' final='false'>
<parameter name='p0' type='long' jni-type='J' />
</method>
<method name='add-BBBBBBB' final='false'>
<parameter name='p0' type='float' jni-type='F' />
</method>
</class>
</package>");
var klass = XmlApiImporter.CreateClass (xml.Root, xml.Root.Element ("class"), new CodeGenerationOptions ());

KotlinFixups.Fixup (new [] { (GenBase) klass }.ToList ());

Assert.AreEqual (2, klass.Methods.Count);
Assert.IsTrue (klass.Methods.All (m => m.Name == "Add"));
}

[Test]
public void MixedCollidingAndUniqueHashSiblings ()
{
// Three siblings of the same source-name: the long+long pair collide,
// the float arg is unique. Expect 2 methods to survive.
var xml = XDocument.Parse (@"<package name='com.example.test' jni-name='com/example/test'>
<class name='test'>
<method name='add-AAAAAAA' final='false'>
<parameter name='p0' type='long' jni-type='J' />
</method>
<method name='add-BBBBBBB' final='false'>
<parameter name='p0' type='long' jni-type='J' />
</method>
<method name='add-CCCCCCC' final='false'>
<parameter name='p0' type='float' jni-type='F' />
</method>
</class>
</package>");
var klass = XmlApiImporter.CreateClass (xml.Root, xml.Root.Element ("class"), new CodeGenerationOptions ());

KotlinFixups.Fixup (new [] { (GenBase) klass }.ToList ());

Assert.AreEqual (2, klass.Methods.Count);
Assert.IsTrue (klass.Methods.All (m => m.Name == "Add"));
CollectionAssert.AreEquivalent (new [] { "long", "float" }, klass.Methods.Select (m => m.Parameters [0].RawNativeType).ToArray ());
}

[Test, NonParallelizable]
public void MangledMethod_CollidesWithNonMangledOverload ()
{
// A pre-existing non-mangled overload `add(long)` plus a mangled
// `add-AAAAAAA(long)` that also reduces to `add(long)` after rename.
// The mangled one is the duplicate -- drop it, keep the non-mangled.
var xml = XDocument.Parse (@"<package name='com.example.test' jni-name='com/example/test'>
<class name='test'>
<method name='add' final='false'>
<parameter name='p0' type='long' jni-type='J' />
</method>
<method name='add-AAAAAAA' final='false'>
<parameter name='p0' type='long' jni-type='J' />
</method>
</class>
</package>");
var klass = XmlApiImporter.CreateClass (xml.Root, xml.Root.Element ("class"), new CodeGenerationOptions ());

using var warnings = CaptureWarnings ();
KotlinFixups.Fixup (new [] { (GenBase) klass }.ToList ());

Assert.AreEqual (1, klass.Methods.Count, "Mangled duplicate should have been removed, leaving the pre-existing non-mangled method.");
Assert.AreEqual ("add", klass.Methods [0].JavaName, "The kept method should be the non-mangled one.");
Assert.IsTrue (warnings.Messages.Any (m => m.Contains ("BG8C02")), "Expected BG8C02 warning.");
}

static WarningCapture CaptureWarnings () => new WarningCapture ();

sealed class WarningCapture : IDisposable
{
readonly Action<TraceLevel, string> previous;
public List<string> Messages { get; } = new List<string> ();

public WarningCapture ()
{
previous = Report.OutputDelegate;
Report.OutputDelegate = (level, msg) => Messages.Add (msg);
}

public void Dispose ()
{
Report.OutputDelegate = previous;
}
}
}
}
Loading