// Copyright Epic Games, Inc. All Rights Reserved. using System; using System.Collections.Generic; using System.IO; using System.Xml.Linq; using Microsoft.Extensions.Logging; namespace UnrealBuildTool { class AndroidAARHandler { public class AndroidAAREntry { public string BaseName; public string Version; public string Filename; public List Dependencies; public AndroidAAREntry(string InBaseName, string InVersion, string InFilename) { BaseName = InBaseName; Version = InVersion; Filename = InFilename; Dependencies = new List(); } public void AddDependency(string InBaseName, string InVersion) { Dependencies.Add(InBaseName); // + "-" + InVersion); will replace version with latest } public void ClearDependencies() { Dependencies.Clear(); } } public List? Repositories = null; public List? AARList = null; private List? JARList = null; /// /// Handler for AAR and JAR dependency determination and staging /// public AndroidAARHandler() { Repositories = new List(); AARList = new List(); JARList = new List(); } /// /// Add a new respository path to search for AAR and JAR files /// /// Directory containing the repository /// Logger for output public void AddRepository(string RepositoryPath, ILogger Logger) { if (Directory.Exists(RepositoryPath)) { Logger.LogInformation("Added repository: {RepositoryPath}", RepositoryPath); Repositories!.Add(RepositoryPath); } else { Logger.LogWarning("AddRepository: Directory {RepositoryPath} not found!", RepositoryPath); } } /// /// Add new respository paths to search for AAR and JAR files (recursive) /// /// Root directory containing the repository /// Search pattern to match /// Logger for output public void AddRepositories(string RepositoryPath, string SearchPattern, ILogger Logger) { if (Directory.Exists(RepositoryPath)) { List ToCheck = new List(); ToCheck.Add(RepositoryPath); while (ToCheck.Count > 0) { int LastIndex = ToCheck.Count - 1; string CurrentDir = ToCheck[LastIndex]; ToCheck.RemoveAt(LastIndex); foreach (string SearchPath in Directory.GetDirectories(CurrentDir)) { if (SearchPath.Contains(SearchPattern)) { Logger.LogInformation("Added repository: {SearchPath}", SearchPath); Repositories!.Add(SearchPath); } else { ToCheck.Add(SearchPath); } } } } else { Logger.LogInformation("AddRepositories: Directory {RepositoryPath} not found; ignored", RepositoryPath); } } public void DumpAAR(ILogger Logger) { Logger.LogInformation("ALL DEPENDENCIES"); foreach (AndroidAAREntry Entry in AARList!) { Logger.LogInformation("{EntryFilename}", Entry.Filename); } foreach (AndroidAAREntry Entry in JARList!) { Logger.LogInformation("{EntryFilename}", Entry.Filename); } } private string GetElementValue(XElement SourceElement, XName ElementName, string DefaultValue) { XElement? Element = SourceElement.Element(ElementName); return (Element != null) ? Element.Value : DefaultValue; } private string? FindPackageFile(string PackageName, string BaseName, string Version) { string[] Sections = PackageName.Split('.'); string PackagePath = Path.Combine(Sections); foreach (string Repository in Repositories!) { string PackageDirectory = Path.Combine(Repository, PackagePath, BaseName, Version); if (Directory.Exists(PackageDirectory)) { return PackageDirectory; } } return null; } private bool HasAnyVersionCharacters(string InValue) { for (int Index = 0; Index < InValue.Length; Index++) { char c = InValue[Index]; if (c == '.' || (c >= '0' && c <= '9')) { return true; } } return false; } private bool HasOnlyVersionCharacters(string InValue) { for (int Index = 0; Index < InValue.Length; Index++) { char c = InValue[Index]; if (c != '.' && !(c >= '0' && c <= '9')) { return false; } } return true; } private uint GetVersionValue(string VersionString) { // read up to 4 sections (ie. 20.0.3.5), first section most significant // each section assumed to be 0 to 255 range uint Value = 0; try { string[] Sections = VersionString.Split(".".ToCharArray()); Value |= (Sections.Length > 0) ? (UInt32.Parse(Sections[0]) << 24) : 0; Value |= (Sections.Length > 1) ? (UInt32.Parse(Sections[1]) << 16) : 0; Value |= (Sections.Length > 2) ? (UInt32.Parse(Sections[2]) << 8) : 0; Value |= (Sections.Length > 3) ? UInt32.Parse(Sections[3]) : 0; } catch (Exception) { // ignore poorly formed version } return Value; } // clean up the version (Maven version info here: https://docs.oracle.com/middleware/1212/core/MAVEN/maven_version.htm) // only going to handle a few cases, not proper ranges (keeps the rightmost valid version which should be highest) // will still return something but will include an error in log, but don't want to throw an exception private string CleanupVersion(string Filename, string InVersion, ILogger Logger) { string WorkVersion = InVersion; // if has commas, keep the rightmost part with actual numbers if (WorkVersion.Contains(',')) { string[] CommaParts = WorkVersion.Split(','); WorkVersion = ""; for (int Index = CommaParts.Length - 1; Index >= 0; Index--) { if (HasAnyVersionCharacters(CommaParts[Index])) { WorkVersion = CommaParts[Index]; break; } } } // if not left with a possibly valid number, stop if (!HasAnyVersionCharacters(WorkVersion)) { Logger.LogError("AAR Dependency file {Filename} version unknown! {InVersion}", Filename, InVersion); return InVersion; } // just remove any parens or brackets left WorkVersion = WorkVersion.Replace("(", "").Replace(")", "").Replace("[", "").Replace("]", ""); // just return it if now looks value if (HasOnlyVersionCharacters(WorkVersion)) { return WorkVersion; } // give an error, not likely going to work, though Logger.LogError("AAR Dependency file {Filename} version unknown! {InVersion}", Filename, InVersion); return InVersion; } /// /// Adds a new required JAR file and resolves dependencies /// /// Name of the package the JAR belongs to in repository /// Directory in repository containing the JAR /// Version of the AAR to use /// Logger instance public void AddNewJAR(string PackageName, string BaseName, string Version, ILogger Logger) { string? BasePath = FindPackageFile(PackageName, BaseName, Version); if (BasePath == null) { Logger.LogError("AAR: Unable to find package {Package}!", PackageName + "/" + BaseName); return; } string BaseFilename = Path.Combine(BasePath, BaseName + "-" + Version); // Check if already added uint NewVersionValue = GetVersionValue(Version); for (int JARIndex = 0; JARIndex < JARList!.Count; JARIndex++) { if (JARList[JARIndex].BaseName == BaseName) { // Is it the same version or older? ignore if so uint EntryVersionValue = GetVersionValue(JARList[JARIndex].Version); if (NewVersionValue <= EntryVersionValue) { return; } Logger.LogInformation("AAR: {BaseName}: {Version1} newer than {Version2}", JARList[JARIndex].BaseName, Version, JARList[JARIndex].Version); // This is a newer version; remove old one JARList.RemoveAt(JARIndex); break; } } //Logger.LogInformation("JAR: {BaseName}", BaseName); AndroidAAREntry AAREntry = new AndroidAAREntry(BaseName, Version, BaseFilename); JARList.Add(AAREntry); // Check for dependencies XDocument DependsXML; string DependencyFilename = BaseFilename + ".pom"; if (File.Exists(DependencyFilename)) { try { DependsXML = XDocument.Load(DependencyFilename); } catch (Exception e) { Logger.LogError("AAR Dependency file {File} parsing error! {Ex}", DependencyFilename, e); return; } } else { Logger.LogError("AAR: Dependency file {DependencyFilename} missing!", DependencyFilename); return; } string NameSpace = DependsXML.Root!.Name.NamespaceName; XName DependencyName = XName.Get("dependency", NameSpace); XName GroupIdName = XName.Get("groupId", NameSpace); XName ArtifactIdName = XName.Get("artifactId", NameSpace); XName VersionName = XName.Get("version", NameSpace); XName ScopeName = XName.Get("scope", NameSpace); XName TypeName = XName.Get("type", NameSpace); foreach (XElement DependNode in DependsXML.Descendants(DependencyName)) { string DepGroupId = GetElementValue(DependNode, GroupIdName, ""); string DepArtifactId = GetElementValue(DependNode, ArtifactIdName, ""); string DepVersion = CleanupVersion(DependencyFilename + "." + DepGroupId + "." + DepArtifactId, GetElementValue(DependNode, VersionName, ""), Logger); string DepScope = GetElementValue(DependNode, ScopeName, "compile"); string DepType = GetElementValue(DependNode, TypeName, "jar"); //Logger.LogInformation("Dependency: {DepGroupId} {DepArtifactId} {DepVersion} {DepScope} {DepType}", DepGroupId, DepArtifactId, DepVersion, DepScope, DepType); // ignore test scope if (DepScope == "test") { continue; } if (DepType == "aar") { AddNewAAR(DepGroupId, DepArtifactId, DepVersion, Logger); } else if (DepType == "jar") { AddNewJAR(DepGroupId, DepArtifactId, DepVersion, Logger); } } } /// /// Adds a new required AAR file and resolves dependencies /// /// Name of the package the AAR belongs to in repository /// Directory in repository containing the AAR /// Version of the AAR to use /// Logger for output /// Optionally process POM file for dependencies (default) public void AddNewAAR(string PackageName, string BaseName, string Version, ILogger Logger, bool HandleDependencies = true) { if (!HandleDependencies) { AndroidAAREntry NewAAREntry = new AndroidAAREntry(BaseName, Version, PackageName); AARList!.Add(NewAAREntry); return; } string? BasePath = FindPackageFile(PackageName, BaseName, Version); if (BasePath == null) { Logger.LogError("AAR: Unable to find package {Package}!", PackageName + "/" + BaseName); return; } string BaseFilename = Path.Combine(BasePath, BaseName + "-" + Version); // Check if already added uint NewVersionValue = GetVersionValue(Version); for (int AARIndex = 0; AARIndex < AARList!.Count; AARIndex++) { if (AARList[AARIndex].BaseName == BaseName) { // Is it the same version or older? ignore if so uint EntryVersionValue = GetVersionValue(AARList[AARIndex].Version); if (NewVersionValue <= EntryVersionValue) { return; } Logger.LogInformation("AAR: {BaseName}: {Version1} newer than {Version2}", AARList[AARIndex].BaseName, Version, AARList[AARIndex].Version); // This is a newer version; remove old one // @TODO: be smarter about dependency cleanup (newer AAR might not need older dependencies) AARList.RemoveAt(AARIndex); break; } } //Logger.LogInformation("AAR: {BaseName}", BaseName); AndroidAAREntry AAREntry = new AndroidAAREntry(BaseName, Version, BaseFilename); AARList.Add(AAREntry); if (!HandleDependencies) { return; } // Check for dependencies XDocument DependsXML; string DependencyFilename = BaseFilename + ".pom"; if (File.Exists(DependencyFilename)) { try { DependsXML = XDocument.Load(DependencyFilename); } catch (Exception e) { Logger.LogError("AAR Dependency file {File} parsing error! {Ex}", DependencyFilename, e); return; } } else { Logger.LogError("AAR: Dependency file {DependencyFilename} missing!", DependencyFilename); return; } string NameSpace = DependsXML.Root!.Name.NamespaceName; XName DependencyName = XName.Get("dependency", NameSpace); XName GroupIdName = XName.Get("groupId", NameSpace); XName ArtifactIdName = XName.Get("artifactId", NameSpace); XName VersionName = XName.Get("version", NameSpace); XName ScopeName = XName.Get("scope", NameSpace); XName TypeName = XName.Get("type", NameSpace); foreach (XElement DependNode in DependsXML.Descendants(DependencyName)) { string DepGroupId = GetElementValue(DependNode, GroupIdName, ""); string DepArtifactId = GetElementValue(DependNode, ArtifactIdName, ""); string DepVersion = CleanupVersion(DependencyFilename + "." + DepGroupId + "." + DepArtifactId, GetElementValue(DependNode, VersionName, ""), Logger); string DepScope = GetElementValue(DependNode, ScopeName, "compile"); string DepType = GetElementValue(DependNode, TypeName, "jar"); //Logger.LogInformation("Dependency: {DepGroupId} {DepArtifactId} {DepVersion} {DepScope} {DepType}", DepGroupId, DepArtifactId, DepVersion, DepScope, DepType); // ignore test scope if (DepScope == "test") { continue; } if (DepType == "aar") { // Add dependency AAREntry.AddDependency(DepArtifactId, DepVersion); AddNewAAR(DepGroupId, DepArtifactId, DepVersion, Logger); } else if (DepType == "jar") { AddNewJAR(DepGroupId, DepArtifactId, DepVersion, Logger); } } } private void MakeDirectoryIfRequiredForFile(string DestFilename) { string DestSubdir = Path.GetDirectoryName(DestFilename)!; if (!Directory.Exists(DestSubdir)) { Directory.CreateDirectory(DestSubdir); } } private void MakeDirectoryIfRequired(string DestDirectory) { if (!Directory.Exists(DestDirectory)) { Directory.CreateDirectory(DestDirectory); } } /// /// Copies the required JAR files to the provided directory /// /// Destination path for JAR files /// Logger for output public void CopyJARs(string DestinationPath, ILogger Logger) { MakeDirectoryIfRequired(DestinationPath); DestinationPath = Path.Combine(DestinationPath, "libs"); MakeDirectoryIfRequired(DestinationPath); foreach (AndroidAAREntry Entry in JARList!) { string Filename = Entry.Filename + ".jar"; string BaseName = Path.GetFileName(Filename); string TargetPath = Path.Combine(DestinationPath, BaseName); //Logger.LogInformation("Attempting to copy JAR {Filename} {BaseName} {TargetPath}", Filename, BaseName, TargetPath); if (!File.Exists(Filename)) { Logger.LogInformation("JAR doesn't exist! {Filename}", Filename); } if (!File.Exists(TargetPath)) { Logger.LogInformation("Copying JAR {BaseName}", BaseName); File.Copy(Filename, TargetPath); } } } /// /// Extracts the required AAR files to the provided directory /// /// Destination path for AAR files /// Name of the package these AARs are being used with /// Logger for output public void ExtractAARs(string DestinationPath, string AppPackageName, ILogger Logger) { MakeDirectoryIfRequired(DestinationPath); DestinationPath = Path.Combine(DestinationPath, "JavaLibs"); MakeDirectoryIfRequired(DestinationPath); Logger.LogInformation("Extracting AARs"); foreach (AndroidAAREntry Entry in AARList!) { string BaseName = Path.GetFileName(Entry.Filename); string TargetPath = Path.Combine(DestinationPath, BaseName); // Only extract if haven't before to prevent changing timestamps string TargetManifestFileName = Path.Combine(TargetPath, "AndroidManifest.xml"); if (!File.Exists(TargetManifestFileName)) { Logger.LogInformation("Extracting AAR {BaseName}", BaseName); /*IEnumerable FileNames =*/ UnzipFiles(Entry.Filename + ".aar", TargetPath, Logger); // Must have a src directory (even if empty) string SrcDirectory = Path.Combine(TargetPath, "src"); if (!Directory.Exists(SrcDirectory)) { Directory.CreateDirectory(SrcDirectory); //FileStream Placeholder = new FileStream(Path.Combine(SrcDirectory, ".placeholder_" + BaseName), FileMode.Create, FileAccess.Write); } // Move the class.jar file string ClassSourceFile = Path.Combine(TargetPath, "classes.jar"); if (File.Exists(ClassSourceFile)) { string ClassDestFile = Path.Combine(TargetPath, "libs", BaseName + ".jar"); MakeDirectoryIfRequiredForFile(ClassDestFile); File.Move(ClassSourceFile, ClassDestFile); } // Now create the project.properties file string PropertyFilename = Path.Combine(TargetPath, "project.properties"); if (!File.Exists(PropertyFilename)) { // Try to get the SDK target from the AndroidManifest.xml string MinSDK = "9"; string ManifestFilename = Path.Combine(TargetPath, "AndroidManifest.xml"); XDocument ManifestXML; if (File.Exists(ManifestFilename)) { try { // Replace all instances of the application id with the packagename string Contents = File.ReadAllText(ManifestFilename); string NewContents = Contents.Replace("${applicationId}", AppPackageName); if (Contents != NewContents) { File.WriteAllText(ManifestFilename, NewContents); } ManifestXML = XDocument.Load(ManifestFilename); XElement UsesSdk = ManifestXML.Root!.Element(XName.Get("uses-sdk", ManifestXML.Root.Name.NamespaceName))!; XAttribute Target = UsesSdk.Attribute(XName.Get("minSdkVersion", "http://schemas.android.com/apk/res/android"))!; MinSDK = Target.Value; } catch (Exception e) { Logger.LogError("AAR Manifest file {ManifestFile} parsing error! {Ex}", ManifestFilename, e); } } // Project contents string ProjectPropertiesContents = "target=android-" + MinSDK + "\nandroid.library=true\n"; // Add the dependencies int RefCount = 0; foreach (string DependencyName in Entry.Dependencies) { // Find the version foreach (AndroidAAREntry ScanEntry in AARList) { if (ScanEntry.BaseName == DependencyName) { RefCount++; ProjectPropertiesContents += "android.library.reference." + RefCount + "=../" + DependencyName + "-" + ScanEntry.Version + "\n"; break; } } } File.WriteAllText(PropertyFilename, ProjectPropertiesContents); } } } } /// /// Extracts the contents of a zip file /// /// Name of the zip file /// Output directory /// Logger for output /// List of files written public static IEnumerable UnzipFiles(string ZipFileName, string BaseDirectory, ILogger Logger) { // manually extract the files. There was a problem with the Ionic.Zip library that required this on non-PC at one point, // but that problem is now fixed. Leaving this code as is as we need to return the list of created files and fix up their permissions anyway. using (Ionic.Zip.ZipFile Zip = new Ionic.Zip.ZipFile(ZipFileName)) { List OutputFileNames = new List(); foreach (Ionic.Zip.ZipEntry Entry in Zip.Entries) { // support-v4 and support-v13 has the jar file named with "internal_impl-XX.X.X.jar" // this causes error "Found 2 versions of internal_impl-XX.X.X.jar" // following codes adds "support-v4-..." to the output jar file name to avoid the collision string OutputFileName = Path.Combine(BaseDirectory, Entry.FileName); if (Entry.FileName.Contains("internal_impl")) { string _ZipName = Path.GetFileNameWithoutExtension(ZipFileName); string NewOutputFileName = Path.Combine(Path.GetDirectoryName(OutputFileName)!, _ZipName + '-' + Path.GetFileNameWithoutExtension(OutputFileName) + '.' + Path.GetExtension(OutputFileName)); Logger.LogInformation("Changed FileName {EntryFileName} => {NewOutputFileName}", Entry.FileName, NewOutputFileName); OutputFileName = NewOutputFileName; } Directory.CreateDirectory(Path.GetDirectoryName(OutputFileName)!); if (!Entry.IsDirectory) { using (FileStream OutputStream = new FileStream(OutputFileName, FileMode.Create, FileAccess.Write)) { Entry.Extract(OutputStream); } OutputFileNames.Add(OutputFileName); } } return OutputFileNames; } } } }