Nuke.Unreal
Build Unreal apps in Style.
Loading...
Searching...
No Matches
IAndroidTargets.cs
1
2using System;
3using System.Collections.Generic;
4using System.Linq;
5using System.IO;
6using System.IO.Compression;
7using Nuke.Common.IO;
8using Newtonsoft.Json;
9using Newtonsoft.Json.Linq;
10using Nuke.Common;
11using Nuke.Common.Utilities;
12using Nuke.Common.Utilities.Collections;
13using Nuke.Common.Tooling;
14using Nuke.Unreal.Ini;
15using Serilog;
16using GlobExpressions;
17using System.Linq.Expressions;
18using System.Text.RegularExpressions;
19using System.Threading;
20using System.Runtime.InteropServices;
21using Nuke.Cola;
22
23namespace Nuke.Unreal
24{
25 [StructLayout(LayoutKind.Sequential)]
26 public struct FLASHWINFO
27 {
28 public UInt32 cbSize;
29 public IntPtr hwnd;
30 public Int32 dwFlags;
31 public UInt32 uCount;
32 public Int32 dwTimeout;
33 }
34 public class AndroidTargets : NukeBuild, IAndroidTargets
35 {
36 public static readonly IAndroidTargets Default = new AndroidTargets();
37 }
38
39 internal partial record AndroidProcess(
40 string User,
41 int Pid,
42 int ParentPid,
43 int Vsz,
44 int Rss,
45 string Wchan,
46 int Addr,
47 string S,
48 string Name
49 ) {
50 [GeneratedRegex(@"(?<USER>\S+)\s+(?<PID>\S+)\s+(?<PPID>\S+)\s+(?<VSZ>\S+)\s+(?<RSS>\S+)\s+(?<WCHAN>\S+)\s+(?<ADDR>\S+)\s+(?<S>\S+)\s+(?<NAME>\S+)")]
51 private static partial Regex AndroidProcessRegex();
52
53 public static AndroidProcess? Make(string line)
54 {
55 var r = AndroidProcessRegex().Matches(line)
56 .FirstOrDefault()?.Groups;
57
58 return r == null ? null : new(
59 r["USER"]?.Value ?? "",
60 int.TryParse(r["PID"]?.Value, out int pid) ? pid : 0,
61 int.TryParse(r["PPID"]?.Value, out int ppid) ? ppid : 0,
62 int.TryParse(r["VSZ"]?.Value, out int vsz) ? vsz : 0,
63 int.TryParse(r["RSS"]?.Value, out int rss) ? rss : 0,
64 r["WCHAN"]?.Value ?? "",
65 int.TryParse(r["ADDR"]?.Value, out int addr) ? addr : 0,
66 r["S"]?.Value ?? "",
67 r["NAME"]?.Value ?? ""
68 );
69 }
70
71 public bool IsAnyIdNull()
72 {
73 return Pid == 0
74 || (new [] {User, S, Wchan, Name}).Any(s => string.IsNullOrWhiteSpace(s));
75 }
76
77 public override string ToString()
78 {
79 return $"User: {User}, Pid: {Pid}, ParentPid: {ParentPid}, Vsz: {Vsz}, Rss: {Rss}, Wchan: {Wchan}, Addr: {Addr}, S: {S}, Name: {Name}";
80 }
81
82 }
83
84 public record class AndroidBuildEnvironment(
85 AbsolutePath ArtifactFolder,
86 AbsolutePath AndroidHome,
87 AbsolutePath NdkFolder,
88 AbsolutePath BuildTools
89 );
90
91 public record class AndroidSdkNdkUserSettings(
92 string? SdkPath,
93 string? NdkPath,
94 string? JavaPath,
95 string? SdkApiLevel,
96 string? NdkApiLevel
97 ) {
98
99 public static readonly string SdkUserSettingsSection = "/Script/AndroidPlatformEditor.AndroidSDKSettings";
100
101 public static string? GetConfig(ConfigIni? from, string key) =>
102 from?[SdkUserSettingsSection]?[key].FirstOrDefault().Value;
103
104 public static string? GetConfigPath(ConfigIni? from, string key) =>
105 GetConfig(from, key)?.Parse(@"Path=""(?<PATH>.*)""")("PATH")!;
106
107 public static void SetConfig(ConfigIni? to, string key, string? value)
108 {
109 if (value != null)
110 to?.FindOrAdd(SdkUserSettingsSection).Set(key, value);
111 }
112
113 public static void SetConfigPath(ConfigIni? to, string key, string? value)
114 {
115 to?.FindOrAdd(SdkUserSettingsSection).Set(key, $"(Path=\"{value}\")");
116 }
117
118 public static AndroidSdkNdkUserSettings From(ConfigIni? ini) => new(
119 GetConfigPath(ini, "SDKPath"),
120 GetConfigPath(ini, "NDKPath"),
121 GetConfigPath(ini, "JavaPath"),
122 GetConfig(ini, "SDKAPILevel"),
123 GetConfig(ini, "NDKAPILevel")
124 );
125
126 public bool IsEmpty =>
127 SdkPath == null
128 && NdkPath == null
129 && JavaPath == null
130 && SdkApiLevel == null
131 && NdkApiLevel == null;
132
133 public ConfigIni ToConfig()
134 {
135 var result = new ConfigIni();
136 SetConfigPath(result, "SDKPath", SdkPath);
137 SetConfigPath(result, "NDKPath", NdkPath);
138 SetConfigPath(result, "JavaPath", JavaPath);
139 SetConfig(result, "SDKAPILevel", SdkApiLevel);
140 SetConfig(result, "NDKAPILevel", NdkApiLevel);
141 return result;
142 }
143
144 public override string? ToString() => IsEmpty ? null : ToConfig().Serialize().Trim();
145
146 public AndroidSdkNdkUserSettings Merge(AndroidSdkNdkUserSettings? from) => this with {
147 SdkPath = from?.SdkPath ?? SdkPath,
148 NdkPath = from?.NdkPath ?? NdkPath,
149 JavaPath = from?.JavaPath ?? JavaPath,
150 SdkApiLevel = from?.SdkApiLevel ?? SdkApiLevel,
151 NdkApiLevel = from?.NdkApiLevel ?? NdkApiLevel
152 };
153 }
154
155 [ParameterPrefix("Android")]
156 public partial interface IAndroidTargets : INukeBuild
157 {
158 T Self<T>() where T : INukeBuild => (T)(object)this;
159 bool IsAndroidPlatform() => Self<UnrealBuild>().Platform == UnrealPlatform.Android;
160
161 [Parameter("Select texture compression mode for Android")]
162 AndroidCookFlavor[] TextureMode
163 => TryGetValue(() => TextureMode)
164 ?? [ AndroidCookFlavor.Multi ];
165
166 string GetAppNameFromConfig()
167 {
168 var packageNameCommands = Self<UnrealBuild>().ReadIniHierarchy("Engine")
169 ?["/Script/AndroidRuntimeSettings.AndroidRuntimeSettings"]
170 ?["PackageName"];
171
172 return packageNameCommands?.IsEmpty() ?? true
173 ? $"com.epicgames.{Self<UnrealBuild>().ProjectName}"
174 : packageNameCommands.First().Value;
175 }
176
177 [Parameter("Specify the full qualified android app name")]
178 string AppName
179 => TryGetValue(() => AppName)
180 ?? GetAppNameFromConfig();
181
182 [Parameter("Processor architecture of your target hardware")]
184 => TryGetValue(() => Cpu)
186
187 [Parameter("Processor architecture of your target hardware")]
188 bool NoUninstall => TryGetValue<bool?>(() => NoUninstall) ?? false;
189
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);
192
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);
195
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);
198
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);
201
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);
204
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);
207
208 AbsolutePath UserEngineIniPath => (AbsolutePath)
209 EnvironmentInfo.SpecialFolder(SpecialFolders.LocalApplicationData)
210 / "Unreal Engine" / "Engine" / "Config" / "UserEngine.ini";
211
212 AbsolutePath UserEngineIniCache => TemporaryDirectory
213 / "Config" / "UserEngine.ini";
214
215 AbsolutePath AndroidHome => (AbsolutePath)
216 EnvironmentInfo.SpecialFolder(SpecialFolders.LocalApplicationData) / "Android" / "Sdk";
217
218 AbsolutePath AndroidNdkRoot => AndroidHome / "ndk";
219 AbsolutePath AndroidBuildToolsRoot => AndroidHome / "build-tools";
220
221 AndroidBuildEnvironment AndroidBoilerplate()
222 {
223 var self = Self<UnrealBuild>();
224 var artifactFolder = self.GetOutput() / $"Android_{TextureMode[0]}";
225 if (!artifactFolder.DirectoryExists())
226 {
227 artifactFolder = self.GetOutput() / "Android";
228 }
229 Assert.DirectoryExists(
230 artifactFolder,
231 $"{artifactFolder} doesn't exist. Did packaging go wrong?"
232 );
233
234 Assert.DirectoryExists(
235 AndroidNdkRoot,
236 $"{AndroidNdkRoot} doesn't exist. Please configure your Android development environment"
237 );
238
239 var ndkFolder = (AbsolutePath) Directory.EnumerateDirectories(AndroidNdkRoot).FirstOrDefault();
240
241 Assert.NotNull(
242 ndkFolder,
243 "There are no NDK subfolders. Please configure your Android development environment"
244 );
245
246 var buildToolsCandidates = BuildToolVersion == null
247 ? AndroidBuildToolsRoot.GlobDirectories("*")
248 : AndroidBuildToolsRoot.GlobDirectories($"{BuildToolVersion}.*");
249
250 if (buildToolsCandidates.IsEmpty())
251 {
252 buildToolsCandidates = AndroidBuildToolsRoot.GlobDirectories("*");
253 }
254 var buildTools = buildToolsCandidates.Last();
255
256 return new(artifactFolder, AndroidHome, ndkFolder, buildTools);
257 }
258
259 string GetApkName()
260 {
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()}";
265 }
266
267 AbsolutePath GetApkFile()
268 {
269 var self = Self<UnrealBuild>();
270 return self.ProjectFolder / "Binaries" / "Android" / (GetApkName() + ".apk");
271 }
272
273 Target ApplySdkUserSettings => _ => _
274 .Description(
275 """
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.
279 """
280 )
281 .OnlyWhenStatic(() => IsAndroidPlatform())
282 .DependentFor<UnrealBuild>(
283 u => u.Build,
284 u => u.Cook
285 )
286 .DependentFor<IPackageTargets>(p => p.Package)
287 .Executes(() =>
288 {
289 string? cachedIniContent = UserEngineIniCache.FileExists() ? File.ReadAllText(UserEngineIniCache) : null;
290 string? sharedIniContent = UserEngineIniPath.FileExists() ? File.ReadAllText(UserEngineIniPath) : null;
291 var cachedIni = ConfigIni.Parse(cachedIniContent);
292 var sharedIni = ConfigIni.Parse(sharedIniContent);
293
294 var cached = AndroidSdkNdkUserSettings.From(cachedIni);
295 var shared = AndroidSdkNdkUserSettings.From(sharedIni);
296
297 string? AppendPrefix(string? input) =>
298 input?.StartsWithOrdinalIgnoreCase("android-") ?? true ? input : "android-" + input;
299
300 var input = new AndroidSdkNdkUserSettings(
301 SdkPath,
302 NdkVersion == null ? null : AndroidNdkRoot.GetVersionSubfolder(NdkVersion),
303 JavaPath,
304 AppendPrefix(SdkApiLevel),
305 AppendPrefix(NdkApiLevel)
306 );
307
308 if (input.IsEmpty && cached.IsEmpty)
309 {
310 Log.Information("No local change is requested");
311 if (!shared.IsEmpty)
312 {
313 Log.Information("User scoped configuration is:\n" + shared.ToString());
314 }
315 return;
316 }
317
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"));
322
323 var result = shared.Merge(cached).Merge(input);
324
325 Log.Information("Result:\n" + (result.ToString() ?? "none"));
326
327 if (!result.IsEmpty)
328 {
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());
335 }
336 });
337
338 Target CleanIntermediateAndroid => _ => _
339 .Description("Clean up the Android folder inside Intermediate")
340 .OnlyWhenStatic(() => IsAndroidPlatform())
341 .DependentFor<UnrealBuild>(ub => ub.Build)
342 .DependentFor<IPackageTargets>(p => p.Package)
343 .Executes(() =>
344 {
345 var self = Self<UnrealBuild>();
346 (self.ProjectFolder / "Intermediate" / "Android").DeleteDirectory();
347 });
348
349 Target SignApk => _ => _
350 .Description("Sign the output APK")
351 .OnlyWhenStatic(() => IsAndroidPlatform())
352 .TriggeredBy<IPackageTargets>(p => p.Package)
353 .Before(InstallOnAndroid, DebugOnAndroid)
354 .After<UnrealBuild>(ub => ub.Build)
355 .Executes(() =>
356 {
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;
362
363 Assert.False(string.IsNullOrWhiteSpace(keyStore), "There was no keystore specified");
364 Assert.True(keystorePath.FileExists(), "Specified keystore was not found");
365
366 if (string.IsNullOrWhiteSpace(password))
367 password = androidRuntimeSettings?.GetFirst("KeyPassword").Value;
368
369 Assert.False(string.IsNullOrWhiteSpace(password), "There was no keystore password specified");
370
371 // save the password in a temporary file so special characters not appreciated by batch will not cause trouble
372 var kspassFile = TemporaryDirectory / "Android" / "kspass";
373 if (!kspassFile.Parent.DirectoryExists())
374 {
375 Directory.CreateDirectory(kspassFile.Parent);
376 }
377 File.WriteAllText(kspassFile, password);
378
379 var androidEnv = AndroidBoilerplate();
380 var apkSignerBat = ToolResolver.GetTool(androidEnv.BuildTools / "apksigner.bat");
381 apkSignerBat(
382 $"sign --ks \"{keystorePath}\" --ks-pass \"file:{kspassFile}\" \"{GetApkFile()}\""
383 );
384 });
385
386 Target InstallOnAndroid => _ => _
387 .Description(
388 """
389 Package and install the product on a connected android device.
390 Only executed when target-platform is set to Android
391 """
392 )
393 .OnlyWhenStatic(() => IsAndroidPlatform())
394 .After<IPackageTargets>(p => p.Package)
395 .After<UnrealBuild>(u => u.Build)
396 .Executes(() =>
397 {
398 var self = Self<UnrealBuild>();
399 var adb = ToolResolver.GetPathTool("adb");
400
401 var androidEnv = AndroidBoilerplate();
402
403 var apkFile = GetApkFile();
404 Assert.True(apkFile.FileExists());
405
406 if (!NoUninstall)
407 {
408 try
409 {
410 Log.Information("Uninstall {0} (failures here are not fatal)", AppName);
411 adb($"uninstall {AppName}");
412 }
413 catch (Exception e)
414 {
415 Log.Warning(e, "Uninstallation threw errors, but that might not be a problem");
416 }
417 }
418
419 Log.Information("Installing {0}", apkFile);
420 adb($"install {apkFile}");
421
422 var storagePath = adb("shell echo $EXTERNAL_STORAGE")
423 .FirstOrDefault(o => !string.IsNullOrWhiteSpace(o.Text))
424 .Text;
425
426 Assert.False(string.IsNullOrWhiteSpace(storagePath), "Couldn't get a storage path from the device");
427
428 if (!NoUninstall)
429 {
430 try
431 {
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}");
438 }
439 catch (Exception e)
440 {
441 Log.Warning(e, "Removing existing asset files threw errors, but that might not be a problem");
442 }
443 }
444
445 var obbName = $"main.1.{AppName}";
446 var obbFile = androidEnv.ArtifactFolder / (obbName + ".obb");
447
448 if (obbFile.FileExists())
449 {
450 Log.Information("Installing {0}", obbFile);
451
452 adb($"push {obbFile} {storagePath}/obb/{AppName}/{obbName}.obb");
453 }
454
455 Log.Information("Grant READ_EXTERNAL_STORAGE and WRITE_EXTERNAL_STORAGE to the apk for reading OBB file or game file in external storage.");
456
457 adb($"shell pm grant {AppName} android.permission.READ_EXTERNAL_STORAGE");
458 adb($"shell pm grant {AppName} android.permission.WRITE_EXTERNAL_STORAGE");
459
460 Log.Information("Done installing {0}", AppName);
461 });
462
463 Target DebugOnAndroid => _ => _
464 .Description(
465 """
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
469 """
470 )
471 .OnlyWhenStatic(() => IsAndroidPlatform())
472 .After(InstallOnAndroid)
473 .Executes(() =>
474 {
475 var adb = ToolResolver.GetPathTool("adb");
476
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");
479 });
480
481 private void FinishKeyReminder()
482 {
483 var (Left, Top) = Console.GetCursorPosition();
484 Console.WriteLine("Press Escape when finished...");
485 Console.SetCursorPosition(Left, Top);
486 }
487
488 [GeneratedRegex(@"/(?:org|com|extensions?)/.*$", RegexOptions.IgnoreCase | RegexOptions.Multiline, "en-GB")]
489 private static partial Regex ComponentFromPathRegex();
490
491 private static string? GetComponentFromPath(AbsolutePath path) =>
492 ComponentFromPathRegex().Match(path.ToString().Replace('\\', '/'))?.Value;
493
494 private static bool ArePathsSharingComponents(AbsolutePath a, AbsolutePath b)
495 {
496 var ac = GetComponentFromPath(a);
497 var bc = GetComponentFromPath(b);
498 return ac?.EqualsOrdinalIgnoreCase(bc ?? "") ?? false;
499 }
500
501 private static AbsolutePath? FindMatchingComponentPath(AbsolutePath reference, AbsolutePath root, bool lookForFiles)
502 {
503 var target = root.SubTreeProject()
504 .Where(s => s.ToString().Replace('\\', '/').ContainsOrdinalIgnoreCase("java/src"));
505
506 if (lookForFiles)
507 {
508 target = target.Concat(target.SelectMany(d => d.GlobFiles("*.*")));
509 }
510 return target.FirstOrDefault(s => ArePathsSharingComponents(reference, s));
511 }
512
513 Target JavaDevelopmentService => _ => _
514 .Description(
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)"
518 )
519 .After<UnrealBuild>(u => u.Build)
520 .After<IPackageTargets>(p => p.Package)
521 .Executes(() =>
522 {
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;
527
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"
532 );
533
534 using var watcher = new FileSystemWatcher(intermediateJavaSourcesFolder)
535 {
536 NotifyFilter = NotifyFilters.LastWrite
537 | NotifyFilters.DirectoryName
538 | NotifyFilters.FileName,
539 EnableRaisingEvents = true,
540 IncludeSubdirectories = true
541 };
542
543 void FileSystemEventBody(object s, FileSystemEventArgs e, bool useFileInComparison, Action<AbsolutePath, AbsolutePath>? onItemDirectory, Action<AbsolutePath, AbsolutePath> onItemFile)
544 {
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);
548 if (target != null)
549 {
550 Log.Information("Found matching source: {0}", target);
551 try
552 {
553 if (path.DirectoryExists()) // Changed item is directory
554 {
555 onItemDirectory?.Invoke(path, target);
556 }
557 else if (path.FileExists()) // Changed item is directory
558 {
559 onItemFile?.Invoke(path, target);
560 }
561 }
562 catch (Exception ex)
563 {
564 Log.Error(ex, "Error during reacting to file-system changes");
565 }
566 }
567 else
568 {
569 Log.Information("No matching source was found. Nothing will happen.");
570 }
571 }
572
573 watcher.Created += (s, e) => FileSystemEventBody(s, e, false,
574 (p, t) => (t / p.Name).CreateDirectory(),
575 (p, t) =>
576 {
577 var content = p.ReadAllText();
578 (t / p.Name).WriteAllText(content);
579 }
580 );
581
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)
585 );
586
587 watcher.Deleted += (s, e) => FileSystemEventBody(s, e, true,
588 (p, t) => t.DeleteDirectory(),
589 (p, t) => t.DeleteFile()
590 );
591
592 watcher.Changed += (s, e) => FileSystemEventBody(s, e, true,
593 null,
594 (p, t) => p.CopyToDirectory(t.Parent, ExistsPolicy.MergeAndOverwrite)
595 );
596
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");
599
600 while(true) {
601 watcher.WaitForChanged(WatcherChangeTypes.All);
602 }
603 });
604 }
605}
The root class representing Unreal INI configuration.
Definition ConfigIni.cs:50
ConfigSection FindOrAdd(string key)
Get an existing section or add it if it doesn't exist yet.
Definition ConfigIni.cs:66
static ? ConfigIni Parse(string? input)
Parse an Unreal configuration text into a ConfigIni.
Definition ConfigIni.cs:82
void Set(string key, string value, CommandType type=CommandType.Set)
Set or add an individual config item.
The main build class Unreal projects using Nuke.Unreal should inherit from. This class contains all b...
High level representation of common platforms supported by Unreal Engine (NDA ones excluded) and extr...
Target for packaging the current project we're working on.