Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@ This file contains MSBuild targets that support building and operating on Androi
<PropertyGroup>
<_AGPOutDirAbs>$(IntermediateOutputPath)gradle/</_AGPOutDirAbs>
<_AGPOutDirAbs Condition=" !$([System.IO.Path]::IsPathRooted('$(_AGPOutDirAbs)')) ">$(MSBuildProjectDirectory)/$(_AGPOutDirAbs)</_AGPOutDirAbs>
<_AGPInitScriptPath>$(_AGPOutDirAbs)net.android.init.gradle.kts</_AGPInitScriptPath>
<!-- Users can override _AGPInitScriptPath to point to their own init.gradle.kts file as an escape hatch -->
<_AGPInitScriptPath Condition=" '$(_AGPInitScriptPath)' == '' ">$(_AGPOutDirAbs)net.android.init.gradle.kts</_AGPInitScriptPath>
<_BuildAndroidGradleProjectsStamp>$(_AndroidStampDirectory)_BuildAndroidGradleProjects.stamp</_BuildAndroidGradleProjectsStamp>
</PropertyGroup>
Comment thread
jonathanpeppers marked this conversation as resolved.

Expand Down Expand Up @@ -74,7 +75,9 @@ This file contains MSBuild targets that support building and operating on Androi
<RemoveDir Directories="@(AndroidGradleProject->'%(OutputPath)outputs')" />

<!-- Create the net.android.init.gradle.kts script used to override the Gradle project output directory -->
<!-- Skip CopyResource if user has overridden _AGPInitScriptPath to point to their own file -->
<CopyResource
Condition=" $(_AGPInitScriptPath.StartsWith('$(_AGPOutDirAbs)')) "
ResourceName="net.android.init.gradle.kts"
OutputPath="$(_AGPInitScriptPath)"
/>
Expand Down Expand Up @@ -117,7 +120,7 @@ This file contains MSBuild targets that support building and operating on Androi
Condition=" '@(AndroidGradleProject->Count())' != '0' "
DependsOnTargets="_CalculateAndroidGradleProjectOutputPath" >
<Gradle ToolPath="%(AndroidGradleProject.RootDir)%(AndroidGradleProject.Directory)"
BuildDirInitScriptPath="%(AndroidGradleProject.OutputPath)$(_AGPInitScriptPath)"
BuildDirInitScriptPath="$(_AGPInitScriptPath)"
Command="clean"
ModuleName="%(AndroidGradleProject.ModuleName)"
OutputPath="%(AndroidGradleProject.OutputPath)"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,10 @@
* https://docs.gradle.org/current/kotlin-dsl/gradle/org.gradle.api.invocation/-gradle/projects-loaded.html
*/
gradle.projectsLoaded {
Comment thread
jonathanpeppers marked this conversation as resolved.
if (gradle.startParameter.projectProperties.containsKey("netAndroidBuildDirOverride")) {
gradle.startParameter.projectProperties["netAndroidBuildDirOverride"]?.let { buildDir ->
rootProject.allprojects {
afterEvaluate {
layout.buildDirectory.set(file(gradle.startParameter.projectProperties["netAndroidBuildDirOverride"]))
layout.buildDirectory.set(file(buildDir))
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -574,5 +574,56 @@ public void TestFacebook () {{
FileAssert.Exists (Path.Combine (Root, builder.ProjectDirectory, proj.OutputPath, $"{moduleName}-release.aar"));
}

/// <summary>
/// Test case data for AGP/Gradle version combinations.
/// Gradle 9.x has stricter Kotlin type checking for null safety.
/// See: https://github.com/dotnet/android/issues/10737
/// </summary>
static IEnumerable<object[]> GetAgpGradleVersionTestData ()
{
// AGP 8.5.0 with Gradle 8.7 - baseline, tests existing behavior
yield return new object[] { "8.5.0", "8.7" };
// AGP 9.0.0 with Gradle 9.1.0 - tests the Gradle 9.x stricter Kotlin type checking fix
// Note: Full version "9.1.0" is required for the download URL to work correctly
yield return new object[] { "9.0.0", "9.1.0" };
}

/// <summary>
/// Verifies that .NET Android binding projects work with various AGP and Gradle versions.
/// This test ensures compatibility with Gradle's evolving Kotlin type checking behavior,
/// particularly the stricter null safety checks introduced in Gradle 9.x.
/// </summary>
[Test]
[TestCaseSource (nameof (GetAgpGradleVersionTestData))]
public void BindLibraryWithMultipleGradleVersions (string agpVersion, string gradleVersion)
{
var gradleProject = AndroidGradleProject.CreateDefault (GradleTestProjectDir, agpVersion, gradleVersion);
var gradleModule = gradleProject.Modules.First ();
var moduleName = gradleModule.Name;

var proj = new XamarinAndroidBindingProject {
Jars = {
new BuildItem (KnownProperties.AndroidGradleProject, gradleProject.BuildFilePath) {
Metadata = {
{ "ModuleName", moduleName },
{ "Bind", "true" },
{ "Configuration", "Release" },
},
},
},
Sources = {
new BuildItem.Source ("Foo.cs") {
TextContent = () => @$"public class Foo {{ public Foo () {{ System.Console.WriteLine (GradleTest.{moduleName}Class.GetString(""TestString"")); }} }}"
},
},
MetadataXml = $@"<metadata><attr path=""/api/package[@name='{gradleModule.PackageName}']"" name=""managedName"">GradleTest</attr></metadata>",
};

using var builder = CreateDllBuilder ();
builder.Verbosity = LoggerVerbosity.Detailed;
Assert.IsTrue (builder.Build (proj), $"Build with AGP {agpVersion} and Gradle {gradleVersion} should have succeeded.");
FileAssert.Exists (Path.Combine (Root, builder.ProjectDirectory, proj.OutputPath, $"{moduleName}-release.aar"));
}

}
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,17 @@ public class AndroidGradleProject

public string BuildFilePath => Path.Combine (ProjectDirectory, "build.gradle.kts");

/// <summary>
/// Android Gradle Plugin version (e.g., "8.5.0", "9.0.0")
/// </summary>
public string AgpVersion { get; set; } = "8.5.0";

/// <summary>
/// Gradle wrapper version to use (e.g., "8.12", "9.0").
/// If null or empty, the Gradle wrapper version generated by gradle init is used.
/// </summary>
public string? GradleVersion { get; set; }

GradleCLI gradleCLI = new GradleCLI ();

public AndroidGradleProject (string directory)
Expand All @@ -26,12 +37,27 @@ public void Create ()
gradleCLI.Init (ProjectDirectory);
var settingsFile = Path.Combine (ProjectDirectory, "settings.gradle.kts");
File.WriteAllText (settingsFile, settings_gradle_kts_content);
File.WriteAllText (BuildFilePath, build_gradle_kts_content);
File.WriteAllText (BuildFilePath, GetBuildGradleKtsContent ());
foreach (var module in Modules) {
module.Create ();
File.AppendAllText (settingsFile, $"{Environment.NewLine}include(\":{module.Name}\")");
}
File.AppendAllText (Path.Combine (ProjectDirectory, "gradle.properties"), "android.useAndroidX=true");

// Update Gradle wrapper version if specified
if (!string.IsNullOrEmpty (GradleVersion)) {
var wrapperPropertiesPath = Path.Combine (ProjectDirectory, "gradle", "wrapper", "gradle-wrapper.properties");
if (File.Exists (wrapperPropertiesPath)) {
var content = File.ReadAllText (wrapperPropertiesPath);
// Replace the distribution URL with the specified Gradle version
content = System.Text.RegularExpressions.Regex.Replace (
content,
@"distributionUrl=.*",
$@"distributionUrl=https\://services.gradle.org/distributions/gradle-{GradleVersion}-bin.zip"
);
File.WriteAllText (wrapperPropertiesPath, content);
}
}
}

public static AndroidGradleProject CreateDefault (string projectDir, bool isApplication = false)
Expand All @@ -47,12 +73,30 @@ public static AndroidGradleProject CreateDefault (string projectDir, bool isAppl
return proj;
}

const string build_gradle_kts_content =
@"
plugins {
id(""com.android.application"") version ""8.5.0"" apply false
id(""com.android.library"") version ""8.5.0"" apply false
}
/// <summary>
/// Creates a default Gradle project with specified AGP and Gradle versions.
/// </summary>
public static AndroidGradleProject CreateDefault (string projectDir, string agpVersion, string? gradleVersion, bool isApplication = false)
{
var proj = new AndroidGradleProject (projectDir) {
AgpVersion = agpVersion,
GradleVersion = gradleVersion,
Modules = {
new AndroidGradleModule (Path.Combine (projectDir, "TestModule")) {
IsApplication = isApplication,
},
},
};
proj.Create ();
return proj;
}

string GetBuildGradleKtsContent () =>
$@"
plugins {{
id(""com.android.application"") version ""{AgpVersion}"" apply false
id(""com.android.library"") version ""{AgpVersion}"" apply false
}}
";
const string settings_gradle_kts_content =
@"
Expand Down
Loading