158 T Self<T>() where T : INukeBuild => (T)(
object)
this;
159 bool IsAndroidPlatform() => Self<UnrealBuild>().Platform ==
UnrealPlatform.Android;
161 [Parameter(
"Select texture compression mode for Android")]
163 => TryGetValue(() => TextureMode)
164 ?? [ AndroidCookFlavor.Multi ];
166 string GetAppNameFromConfig()
168 var packageNameCommands = Self<UnrealBuild>().ReadIniHierarchy(
"Engine")
169 ?[
"/Script/AndroidRuntimeSettings.AndroidRuntimeSettings"]
172 return packageNameCommands?.IsEmpty() ??
true
173 ? $
"com.epicgames.{Self<UnrealBuild>().ProjectName}"
174 : packageNameCommands.First().Value;
177 [Parameter(
"Specify the full qualified android app name")]
179 => TryGetValue(() => AppName)
180 ?? GetAppNameFromConfig();
182 [Parameter(
"Processor architecture of your target hardware")]
184 => TryGetValue(() => Cpu)
187 [Parameter(
"Processor architecture of your target hardware")]
188 bool NoUninstall => TryGetValue<bool?>(() => NoUninstall) ??
false;
190 [Parameter(
"Specify version of the Android build tools to use. Latest will be used by default, or when the specified version is not found")]
191 int? BuildToolVersion => TryGetValue<int?>(() => BuildToolVersion);
193 [Parameter(
"Android SDK version or path. If not specified a local cache will be used. If that doesn't exist the global user settings will be used")]
194 AbsolutePath SdkPath => TryGetValue(() => SdkPath);
196 [Parameter(
"Android NDK version or path. If not specified a local cache will be used. If that doesn't exist the global user settings will be used")]
197 string NdkVersion => TryGetValue(() => NdkVersion);
199 [Parameter(
"Absolute path to Android Java. If not specified a local cache will be used. If that doesn't exist the global user settings will be used")]
200 string JavaPath => TryGetValue(() => JavaPath);
202 [Parameter(
"If not specified a local cache will be used. If that doesn't exist the global user settings will be used")]
203 string SdkApiLevel => TryGetValue(() => SdkApiLevel);
205 [Parameter(
"If not specified a local cache will be used. If that doesn't exist the global user settings will be used")]
206 string NdkApiLevel => TryGetValue(() => NdkApiLevel);
208 AbsolutePath UserEngineIniPath => (AbsolutePath)
209 EnvironmentInfo.SpecialFolder(SpecialFolders.LocalApplicationData)
210 /
"Unreal Engine" /
"Engine" /
"Config" /
"UserEngine.ini";
212 AbsolutePath UserEngineIniCache => TemporaryDirectory
213 /
"Config" /
"UserEngine.ini";
215 AbsolutePath AndroidHome => (AbsolutePath)
216 EnvironmentInfo.SpecialFolder(SpecialFolders.LocalApplicationData) /
"Android" /
"Sdk";
218 AbsolutePath AndroidNdkRoot => AndroidHome /
"ndk";
219 AbsolutePath AndroidBuildToolsRoot => AndroidHome /
"build-tools";
221 AndroidBuildEnvironment AndroidBoilerplate()
223 var
self = Self<UnrealBuild>();
224 var artifactFolder =
self.GetOutput() / $
"Android_{TextureMode[0]}";
225 if (!artifactFolder.DirectoryExists())
227 artifactFolder =
self.GetOutput() /
"Android";
229 Assert.DirectoryExists(
231 $
"{artifactFolder} doesn't exist. Did packaging go wrong?"
234 Assert.DirectoryExists(
236 $
"{AndroidNdkRoot} doesn't exist. Please configure your Android development environment"
239 var ndkFolder = (AbsolutePath) Directory.EnumerateDirectories(AndroidNdkRoot).FirstOrDefault();
243 "There are no NDK subfolders. Please configure your Android development environment"
246 var buildToolsCandidates = BuildToolVersion ==
null
247 ? AndroidBuildToolsRoot.GlobDirectories(
"*")
248 : AndroidBuildToolsRoot.GlobDirectories($
"{BuildToolVersion}.*");
250 if (buildToolsCandidates.IsEmpty())
252 buildToolsCandidates = AndroidBuildToolsRoot.GlobDirectories(
"*");
254 var buildTools = buildToolsCandidates.Last();
256 return new(artifactFolder, AndroidHome, ndkFolder, buildTools);
261 var
self = Self<UnrealBuild>();
262 return self.Config[0] == UnrealConfig.Development
263 ? $
"{self.ProjectName}-{Cpu.ToString().ToLower()}"
264 : $
"{self.ProjectName}-Android-{self.Config[0]}-{Cpu.ToString().ToLower()}";
267 AbsolutePath GetApkFile()
269 var
self = Self<UnrealBuild>();
270 return self.ProjectFolder /
"Binaries" /
"Android" / (GetApkName() +
".apk");
273 Target ApplySdkUserSettings => _ => _
276 For some cursed reason Epic decided to store crucial project breaking build settings
277 in a user scoped shared location (AppData/Local). This target attempts to make
278 it less shared info, so one project compilation doesn't break the other one.
281 .OnlyWhenStatic(() => IsAndroidPlatform())
286 .DependentFor<IPackageTargets>(p => p.Package)
289 string? cachedIniContent = UserEngineIniCache.FileExists() ? File.ReadAllText(UserEngineIniCache) :
null;
290 string? sharedIniContent = UserEngineIniPath.FileExists() ? File.ReadAllText(UserEngineIniPath) :
null;
294 var cached = AndroidSdkNdkUserSettings.From(cachedIni);
295 var shared = AndroidSdkNdkUserSettings.From(sharedIni);
297 string? AppendPrefix(
string? input) =>
298 input?.StartsWithOrdinalIgnoreCase(
"android-") ??
true ? input :
"android-" + input;
300 var input =
new AndroidSdkNdkUserSettings(
302 NdkVersion ==
null ?
null : AndroidNdkRoot.GetVersionSubfolder(NdkVersion),
304 AppendPrefix(SdkApiLevel),
305 AppendPrefix(NdkApiLevel)
308 if (input.IsEmpty && cached.IsEmpty)
310 Log.Information(
"No local change is requested");
313 Log.Information(
"User scoped configuration is:\n" + shared.ToString());
318 Log.Debug(
"SDK User settings are");
319 Log.Debug(
"User Scoped:\n" + (shared.ToString() ??
"none"));
320 Log.Debug(
"Cached:\n" + (cached.ToString() ??
"none"));
321 Log.Debug(
"Command Line:\n" + (input.ToString() ??
"none"));
323 var result = shared.Merge(cached).Merge(input);
325 Log.Information(
"Result:\n" + (result.ToString() ??
"none"));
329 Log.Information(
"Writing configuration to cache");
330 Directory.CreateDirectory(UserEngineIniCache.Parent);
331 File.WriteAllText(UserEngineIniCache, result.ToString());
332 Log.Information(
"Writing configuration to User shared settings");
333 Directory.CreateDirectory(UserEngineIniPath.Parent);
334 File.WriteAllText(UserEngineIniPath, result.ToString());
338 Target CleanIntermediateAndroid => _ => _
339 .Description(
"Clean up the Android folder inside Intermediate")
340 .OnlyWhenStatic(() => IsAndroidPlatform())
342 .DependentFor<IPackageTargets>(p => p.Package)
345 var
self = Self<UnrealBuild>();
346 (self.ProjectFolder /
"Intermediate" /
"Android").DeleteDirectory();
349 Target SignApk => _ => _
350 .Description(
"Sign the output APK")
351 .OnlyWhenStatic(() => IsAndroidPlatform())
353 .Before(InstallOnAndroid, DebugOnAndroid)
357 var
self = Self<UnrealBuild>();
358 var androidRuntimeSettings =
self.ReadIniHierarchy(
"Engine")?[
"/Script/AndroidRuntimeSettings.AndroidRuntimeSettings"];
359 var keyStore = androidRuntimeSettings?.GetFirst(
"KeyStore").Value;
360 var password = androidRuntimeSettings?.GetFirst(
"KeyStorePassword").Value;
361 var keystorePath = self.ProjectFolder /
"Build" /
"Android" / keyStore;
363 Assert.False(
string.IsNullOrWhiteSpace(keyStore),
"There was no keystore specified");
364 Assert.True(keystorePath.FileExists(),
"Specified keystore was not found");
366 if (
string.IsNullOrWhiteSpace(password))
367 password = androidRuntimeSettings?.GetFirst(
"KeyPassword").Value;
369 Assert.False(
string.IsNullOrWhiteSpace(password),
"There was no keystore password specified");
372 var kspassFile = TemporaryDirectory /
"Android" /
"kspass";
373 if (!kspassFile.Parent.DirectoryExists())
375 Directory.CreateDirectory(kspassFile.Parent);
377 File.WriteAllText(kspassFile, password);
379 var androidEnv = AndroidBoilerplate();
380 var apkSignerBat = ToolResolver.GetTool(androidEnv.BuildTools /
"apksigner.bat");
382 $
"sign --ks \"{keystorePath}\" --ks-pass \"file:{kspassFile}\" \"{GetApkFile()}\""
386 Target InstallOnAndroid => _ => _
389 Package and install the product on a connected android device.
390 Only executed when target-platform is set to Android
393 .OnlyWhenStatic(() => IsAndroidPlatform())
395 .After<UnrealBuild>(u => u.Build)
398 var
self = Self<UnrealBuild>();
399 var adb = ToolResolver.GetPathTool(
"adb");
401 var androidEnv = AndroidBoilerplate();
403 var apkFile = GetApkFile();
404 Assert.True(apkFile.FileExists());
410 Log.Information(
"Uninstall {0} (failures here are not fatal)", AppName);
411 adb($
"uninstall {AppName}");
415 Log.Warning(e,
"Uninstallation threw errors, but that might not be a problem");
419 Log.Information(
"Installing {0}", apkFile);
420 adb($
"install {apkFile}");
422 var storagePath = adb(
"shell echo $EXTERNAL_STORAGE")
423 .FirstOrDefault(o => !
string.IsNullOrWhiteSpace(o.Text))
426 Assert.False(
string.IsNullOrWhiteSpace(storagePath),
"Couldn't get a storage path from the device");
432 Log.Information(
"Removing existing assets from device (failures here are not fatal)");
433 adb($
"shell rm -r {storagePath}/UE4Game/{self.ProjectName}");
434 adb($
"shell rm -r {storagePath}/UE4Game/UE4CommandLine.txt");
435 adb($
"shell rm -r {storagePath}/obb/{AppName}");
436 adb($
"shell rm -r {storagePath}/Android/obb/{AppName}");
437 adb($
"shell rm -r {storagePath}/Download/obb/{AppName}");
441 Log.Warning(e,
"Removing existing asset files threw errors, but that might not be a problem");
445 var obbName = $
"main.1.{AppName}";
446 var obbFile = androidEnv.ArtifactFolder / (obbName +
".obb");
448 if (obbFile.FileExists())
450 Log.Information(
"Installing {0}", obbFile);
452 adb($
"push {obbFile} {storagePath}/obb/{AppName}/{obbName}.obb");
455 Log.Information(
"Grant READ_EXTERNAL_STORAGE and WRITE_EXTERNAL_STORAGE to the apk for reading OBB file or game file in external storage.");
457 adb($
"shell pm grant {AppName} android.permission.READ_EXTERNAL_STORAGE");
458 adb($
"shell pm grant {AppName} android.permission.WRITE_EXTERNAL_STORAGE");
460 Log.Information(
"Done installing {0}", AppName);
463 Target DebugOnAndroid => _ => _
466 Launch the product on android but wait for debugger.
467 This requires ADB to be in your PATH and NDK to be correctly configured.
468 Only executed when target-platform is set to Android
471 .OnlyWhenStatic(() => IsAndroidPlatform())
472 .After(InstallOnAndroid)
475 var adb = ToolResolver.GetPathTool(
"adb");
477 Log.Information(
"Running {0} but wait for a debugger to be attached", AppName);
478 adb($
"shell am start -D -n {AppName}/com.epicgames.ue4.GameActivity");
481 private void FinishKeyReminder()
483 var (Left, Top) = Console.GetCursorPosition();
484 Console.WriteLine(
"Press Escape when finished...");
485 Console.SetCursorPosition(Left, Top);
488 [GeneratedRegex(
@"/(?:org|com|extensions?)/.*$", RegexOptions.IgnoreCase | RegexOptions.Multiline,
"en-GB")]
489 private static partial Regex ComponentFromPathRegex();
491 private static string? GetComponentFromPath(AbsolutePath path) =>
492 ComponentFromPathRegex().Match(path.ToString().Replace(
'\\',
'/'))?.Value;
494 private static bool ArePathsSharingComponents(AbsolutePath a, AbsolutePath b)
496 var ac = GetComponentFromPath(a);
497 var bc = GetComponentFromPath(b);
498 return ac?.EqualsOrdinalIgnoreCase(bc ??
"") ??
false;
501 private static AbsolutePath? FindMatchingComponentPath(AbsolutePath reference, AbsolutePath root,
bool lookForFiles)
503 var target = root.SubTreeProject()
504 .Where(s => s.ToString().Replace(
'\\',
'/').ContainsOrdinalIgnoreCase(
"java/src"));
508 target = target.Concat(target.SelectMany(d => d.GlobFiles(
"*.*")));
510 return target.FirstOrDefault(s => ArePathsSharingComponents(reference, s));
513 Target JavaDevelopmentService => _ => _
515 "This is a service which synchronizes Java sources from the Intermediate Gradle project back to plugin sources."
516 +
"\nHit Escape when it is finished."
517 +
"\nThis Target assumes the plugin Java sources are also organized into the package compliant name pattern (com/company/product/etc)"
520 .After<IPackageTargets>(p => p.Package)
523 var
self = Self<UnrealBuild>();
524 var gradleProjectFolder = self.ProjectFolder /
"Intermediate" /
"Android" /
"arm64" /
"gradle";
525 var intermediateJavaSourcesFolder = gradleProjectFolder /
"app" /
"src" /
"main" /
"java";
526 var searchRoot =
self.PluginsFolder;
528 Assert.DirectoryExists(
529 intermediateJavaSourcesFolder,
530 "Generated Gradle project doesn't exist, did you successfully build the project for Anroid?"
531 +
"\nnuke build --platform Android"
534 using var watcher =
new FileSystemWatcher(intermediateJavaSourcesFolder)
536 NotifyFilter = NotifyFilters.LastWrite
537 | NotifyFilters.DirectoryName
538 | NotifyFilters.FileName,
539 EnableRaisingEvents =
true,
540 IncludeSubdirectories =
true
543 void FileSystemEventBody(
object s, FileSystemEventArgs e,
bool useFileInComparison, Action<AbsolutePath, AbsolutePath>? onItemDirectory, Action<AbsolutePath, AbsolutePath> onItemFile)
545 var path = (AbsolutePath) ((e as RenamedEventArgs)?.OldFullPath ?? e.FullPath);
546 Log.Information(
"Item change: {0} | {1}", path, e.ChangeType);
547 var target = FindMatchingComponentPath(useFileInComparison ? path : path.Parent, searchRoot, useFileInComparison);
550 Log.Information(
"Found matching source: {0}", target);
553 if (path.DirectoryExists())
555 onItemDirectory?.Invoke(path, target);
557 else if (path.FileExists())
559 onItemFile?.Invoke(path, target);
564 Log.Error(ex,
"Error during reacting to file-system changes");
569 Log.Information(
"No matching source was found. Nothing will happen.");
573 watcher.Created += (s, e) => FileSystemEventBody(s, e,
false,
574 (p, t) => (t / p.Name).CreateDirectory(),
577 var content = p.ReadAllText();
578 (t / p.Name).WriteAllText(content);
582 watcher.Renamed += (s, e) => FileSystemEventBody(s, e,
true,
583 (p, t) => t.Rename(e.Name, ExistsPolicy.DirectoryMerge | ExistsPolicy.FileFail),
584 (p, t) => t.Rename(e.Name, ExistsPolicy.Fail)
587 watcher.Deleted += (s, e) => FileSystemEventBody(s, e,
true,
588 (p, t) => t.DeleteDirectory(),
589 (p, t) => t.DeleteFile()
592 watcher.Changed += (s, e) => FileSystemEventBody(s, e,
true,
594 (p, t) => p.CopyToDirectory(t.Parent, ExistsPolicy.MergeAndOverwrite)
597 Log.Information(
"Now you can start Android Studio and load the gradle project at\n{0}", gradleProjectFolder);
598 Log.Information(
"Just close with Ctrl+C when finished");
601 watcher.WaitForChanged(WatcherChangeTypes.All);