// Copyright Epic Games, Inc. All Rights Reserved. using System; using System.Collections.Generic; using System.IO; using AutomationTool; using UnrealBuildTool; using System.Text.RegularExpressions; using System.Linq; using System.Text; using System.Threading; using EpicGames.Core; using System.Diagnostics; using static AutomationTool.CommandUtils; namespace Gauntlet { /// /// Android implementation of a device that can run applications /// public class TargetDeviceAndroid : ITargetDevice { #region ITargetDevice public string Name { get; protected set; } public UnrealTargetPlatform? Platform => UnrealTargetPlatform.Android; public CommandUtils.ERunOptions RunOptions { get; set; } public bool IsConnected => IsAvailable; public bool IsOn { get { string CommandLine = "shell dumpsys power"; IProcessResult OnAndUnlockedQuery = RunAdbDeviceCommand(CommandLine, bPauseErrorParsing: true); return OnAndUnlockedQuery.Output.Contains("mHoldingDisplaySuspendBlocker=true") && OnAndUnlockedQuery.Output.Contains("mHoldingWakeLockSuspendBlocker=true"); } } public bool IsAvailable { get { // ensure our device is present in 'adb devices' output. var AllDevices = GetAllConnectedDevices(); if (AllDevices.Keys.Contains(DeviceName) == false) { return false; } if (AllDevices[DeviceName] != "device") { Log.Warning(KnownLogEvents.Gauntlet_DeviceEvent, "Device {Name} is '{State}'", DeviceName, AllDevices[DeviceName]); return false; } // any device will do, but only one at a time. return true; } } #endregion /// /// Low-level device name /// public string DeviceName { get; protected set; } /// /// Temp path we use to push/pull things from the device /// public string LocalCachePath { get; protected set; } /// /// Root storage path of the device, e.g. /sdcard/ /// public string StoragePath { get; protected set; } /// /// External storage path on the device, e.g. /sdcard/UEGame/ etc.. /// public string DeviceExternalStorageSavedPath { get; protected set; } /// /// External files path on the device, this is the app's own publically accessible data directory e.g. /sdcard/Android/data/[com.package.name]/files/UnrealGame etc.. /// public string DeviceExternalFilesSavedPath { get; protected set; } /// /// Saved storage path, e.g. /sdcard/UEGame/Saved (bulk) or /sdcard/Android/data/[com.package.name]/files/UEGame/Saved (notbulk) /// public string DeviceArtifactPath { get; protected set; } /// /// Path to the log file. (This takes public logs setting of the package in to account.) /// public string DeviceLogPath { get; protected set; } [AutoParam((int)(60 * 15))] public int MaxInstallTime { get; protected set; } /// /// Path to a command line if installed /// protected string CommandLineFilePath { get; set; } protected bool IsExistingDevice = false; protected Dictionary LocalDirectoryMappings; private string NoPlayProtectSetting = null; public TargetDeviceAndroid(string InDeviceName = "", AndroidDeviceData DeviceData = null, string InCachePath = null) { AutoParam.ApplyParamsAndDefaults(this, Globals.Params.AllArguments); AdbCredentialCache.AddInstance(DeviceData); // If no device name or its 'default' then use the first default device if (string.IsNullOrEmpty(InDeviceName) || InDeviceName.Equals("default", StringComparison.OrdinalIgnoreCase)) { var DefaultDevices = GetAllAvailableDevices(); if (DefaultDevices.Count() == 0) { if (GetAllConnectedDevices().Count > 0) { throw new AutomationException("No default device available. One or more devices are connected but unauthorized or offline. See 'adb devices'"); } else { throw new AutomationException("No default device available. See 'adb devices'"); } } DeviceName = DefaultDevices.First(); } else { DeviceName = InDeviceName; } if (Log.IsVerbose) { RunOptions = CommandUtils.ERunOptions.NoWaitForExit; } else { RunOptions = CommandUtils.ERunOptions.NoWaitForExit | CommandUtils.ERunOptions.NoLoggingOfRunCommand; } // if this is not a connected device then remove when done var ConnectedDevices = GetAllConnectedDevices(); IsExistingDevice = ConnectedDevices.Keys.Contains(DeviceName); if (!IsExistingDevice && !DeviceName.Contains(":")) { // adb uses port 5555 by default DeviceName += ":5555"; IsExistingDevice = ConnectedDevices.Keys.Contains(DeviceName); } if (!IsExistingDevice) { lock (Globals.MainLock) { using (var PauseEC = new ScopedSuspendECErrorParsing()) { IProcessResult AdbResult = RunAdbGlobalCommand(string.Format("connect {0}", DeviceName)); if (AdbResult.ExitCode != 0) { throw new DeviceException("adb failed to connect to {0}. {1}", DeviceName, AdbResult.Output); } } Log.Info("Connecting to {0}", DeviceName); // Need to sleep for adb service process to register, otherwise get an unauthorized (especially on parallel device use) Thread.Sleep(5000); } ConnectedDevices = GetAllConnectedDevices(); // sanity check that it was now found if (!ConnectedDevices.Keys.Contains(DeviceName)) { throw new DeviceException("Failed to find new device {0} in connection list", DeviceName); } } if (ConnectedDevices[DeviceName] != "device") { Dispose(); throw new DeviceException("Device {0} is '{1}'.", DeviceName, ConnectedDevices[DeviceName]); } // Get the external storage path from the device (once the device is validated as connected) IProcessResult StorageQueryResult = RunAdbDeviceCommand(AndroidPlatform.GetStorageQueryCommand()); if (StorageQueryResult.ExitCode != 0) { throw new DeviceException("Failed to query external storage setup on {0}", DeviceName); } StoragePath = StorageQueryResult.Output.Trim(); LocalDirectoryMappings = new Dictionary(); // for IP devices need to sanitize this Name = DeviceName.Replace(":", "_"); // Temp path used when pulling artifact LocalCachePath = InCachePath ?? Path.Combine(Globals.TempDir, "AndroidDevice_" + Name); } ~TargetDeviceAndroid() { Dispose(false); } #region IDisposable Support private bool DisposedValue = false; // To detect redundant calls protected virtual void Dispose(bool disposing) { if (!DisposedValue) { try { if (!string.IsNullOrEmpty(NoPlayProtectSetting)) { Log.Verbose("Restoring play protect for this session..."); RunAdbDeviceCommand($"shell settings put global package_verifier_user_consent {NoPlayProtectSetting}"); } if (!IsExistingDevice) { // disconnect RunAdbGlobalCommand(string.Format("disconnect {0}", DeviceName), true, false, true); Log.Info("Disconnected {0}", DeviceName); } } catch (Exception Ex) { Log.Warning(KnownLogEvents.Gauntlet_DeviceEvent, "TargetDeviceAndroid.Dispose() threw: {Exception}", Ex.Message); } finally { DisposedValue = true; AdbCredentialCache.RemoveInstance(); } } } // This code added to correctly implement the disposable pattern. public void Dispose() { // Do not change this code. Put cleanup code in Dispose(bool disposing) above. Dispose(true); // TODO: uncomment the following line if the finalizer is overridden above. // GC.SuppressFinalize(this); } public bool Disposed { get { return DisposedValue; } } #endregion #region ITargetDevice public bool Connect() { AllowDeviceSleepState(true); return true; } public bool Disconnect(bool bForce = false) { AllowDeviceSleepState(false); return true; } public bool PowerOn() { Log.Verbose("{0}: Powering on", ToString()); string CommandLine = "shell \"input keyevent KEYCODE_WAKEUP && input keyevent KEYCODE_MENU\""; IProcessResult PowerOnQuery = RunAdbDeviceCommand(CommandLine, bPauseErrorParsing: true); return !PowerOnQuery.Output.Contains("error"); } public bool PowerOff() { Log.Verbose("{0}: Powering off", ToString()); string CommandLine = "shell \"input keyevent KEYCODE_SLEEP\""; IProcessResult PowerOffQuery = RunAdbDeviceCommand(CommandLine, bPauseErrorParsing: true); return !PowerOffQuery.Output.Contains("error"); } public bool Reboot() { return true; } public Dictionary GetPlatformDirectoryMappings() { return LocalDirectoryMappings; } public override string ToString() { // TODO: device id if (Name == DeviceName) { return Name; } return string.Format("{0} ({1})", Name, DeviceName); } public void FullClean() { string StorageLocation = RunAdbDeviceCommand(AndroidPlatform.GetStorageQueryCommand()).Output.Trim(); // Clean up any basic UE/System related directories RunAdbDeviceCommand(string.Format("shell rm -r {0}/UnrealGame/*", StorageLocation)); RunAdbDeviceCommand(string.Format("shell rm -r {0}/Download/*", StorageLocation)); RunAdbDeviceCommand(string.Format("shell rm -r {0}/gdeps/*", StorageLocation)); // Now find all third party packages/obbs and remove them string PackageOutput = RunAdbDeviceCommandAndGetOutput("shell pm list packages -3"); // Ex. package:com.epicgames.lyra\r\n Regex PackageRegex = new Regex("(package:)(.*)(\\r\\n)"); IEnumerable Packages = PackageRegex.Matches(PackageOutput).Select(Match => Match.Groups[2].Value); foreach (string Package in Packages) { Log.Info("Uninstalling {PackageName}...", Package); RunAdbDeviceCommand(string.Format("uninstall {0}", Package)); RunAdbDeviceCommand(string.Format("shell rm -r {0}/Android/data/{1}", StorageLocation, Package)); RunAdbDeviceCommand(string.Format("shell rm -r {0}/Android/obb/{1}", StorageLocation, Package)); } } public void CleanArtifacts(UnrealAppConfig AppConfig = null) { if (AppConfig == null) { Log.Warning("{Platform} expects an non-null AppConfig value to reliably determine which artifact path to clear! Skipping clean", Platform); return; } if (AppConfig.Build is not AndroidBuild Build) { return; } string ExternalStoragePath = StoragePath + "/UnrealGame/" + AppConfig.ProjectName; string ExternalFilesPath = StoragePath + "/Android/data/" + Build.AndroidPackageName + "/files/UnrealGame/" + AppConfig.ProjectName; string ExternalAppStorage = string.Format("{0}/{1}/Saved", ExternalStoragePath, AppConfig.ProjectName); string ExternalAppFiles = string.Format("{0}/{1}/Saved", ExternalFilesPath, AppConfig.ProjectName); RunAdbDeviceCommand(string.Format("shell rm -r {0}/*", ExternalAppStorage)); RunAdbDeviceCommand(string.Format("shell rm -r {0}/*", ExternalAppFiles)); } public void InstallBuild(UnrealAppConfig AppConfig) { AndroidBuild Build = AppConfig.Build as AndroidBuild; if (Build == null) { throw new AutomationException("Unsupported build type {0} for Android!", AppConfig.Build.GetType()); } string Package = Build.AndroidPackageName; KillRunningProcess(Package); // Install apk string APK = Globals.IsRunningDev && AppConfig.OverlayExecutable.GetOverlay(Build.SourceApkPath, out string OverlayAPK) ? OverlayAPK : Build.SourceApkPath; CopyFileToDevice(Package, APK, string.Empty); EnablePermissions(Package); // Copy obbs from bulk builds bool bSkipOBBInstall = Globals.Params.ParseParam("SkipOBBCopy"); // useful when iterating on dev executables if(Build.FilesToInstall != null && Build.FilesToInstall.Any() && !bSkipOBBInstall) { CopyOBBFiles(AppConfig, Build.FilesToInstall, Package); } } public IAppInstall CreateAppInstall(UnrealAppConfig AppConfig) { if(AppConfig.Build is not AndroidBuild Build) { throw new AutomationException("Unsupported build type {0} for Android!", AppConfig.Build.GetType()); } string ExternalStoragePath = StoragePath + "/UnrealGame/" + AppConfig.ProjectName; string ExternalFilesPath = StoragePath + "/Android/data/" + Build.AndroidPackageName + "/files/UnrealGame/" + AppConfig.ProjectName; DeviceExternalStorageSavedPath = string.Format("{0}/{1}/Saved", ExternalStoragePath, AppConfig.ProjectName); DeviceExternalFilesSavedPath = string.Format("{0}/{1}/Saved", ExternalFilesPath, AppConfig.ProjectName); DeviceLogPath = string.Format("{0}/Logs/{1}.log", Build.UsesPublicLogs ? DeviceExternalFilesSavedPath : DeviceExternalStorageSavedPath, AppConfig.ProjectName); DeviceArtifactPath = Build.UsesExternalFilesDir ? DeviceExternalFilesSavedPath : DeviceExternalStorageSavedPath; PopulateDirectoryMappings(Path.GetDirectoryName(DeviceArtifactPath)); return new AndroidAppInstall(AppConfig, this, AppConfig.ProjectName, Build.AndroidPackageName, AppConfig.CommandLine, AppConfig.ProjectFile, AppConfig.Build.Configuration); } public void CopyAdditionalFiles(IEnumerable FilesToCopy) { foreach (UnrealFileToCopy FileToCopy in FilesToCopy) { string DestinationFile = string.Format("{0}/{1}", LocalDirectoryMappings[FileToCopy.TargetBaseDirectory], FileToCopy.TargetRelativeLocation); if (File.Exists(FileToCopy.SourceFileLocation)) { FileInfo SourceFile = new FileInfo(FileToCopy.SourceFileLocation); SourceFile.IsReadOnly = false; CopyFileToDevice(null, SourceFile.FullName, DestinationFile); // todo: add AFS support } else { Log.Warning(KnownLogEvents.Gauntlet_DeviceEvent, "File to copy {File} not found", FileToCopy); } } } public IAppInstance Run(IAppInstall App) { if (App is not AndroidAppInstall Install) { throw new AutomationException("AppInstall is of incorrect type {0}! Must be of type AndroidAppInstall", App.GetType().Name); } if(!IsOn) { PowerOn(); } // Now, create and push the UECommandline.txt file bool bUseExternalFileDirectory = (Install.AppConfig.Build as AndroidBuild)?.UsesExternalFilesDir ?? false; CopyCommandlineFile(Install.AppConfig, Install.AppConfig.CommandLine, Install.AndroidPackageName, Install.AppConfig.ProjectName, bUseExternalFileDirectory); // kill any currently running instance: KillRunningProcess(Install.AndroidPackageName); string LaunchActivity = AndroidPlatform.GetLaunchableActivityName(Install.ApkPath); Log.Info("Launching {0} on '{1}' ", Install.AndroidPackageName + "/" + LaunchActivity, ToString()); Log.Verbose("\t{0}", Install.CommandLine); // Clear the device's logcat in preparation for the test.. RunAdbDeviceCommand("logcat --clear"); // Ensure artifact directories exist if (UsingAndroidFileServer(Install.ProjectFile, Install.Configuration, out _, out string AFSToken, out _, out _, out _)) { RunAFSDeviceCommand(string.Format("-p \"{0}\" -k \"{1}\" mkdir \"^saved\"", Install.AndroidPackageName, AFSToken)); } else { RunAdbDeviceCommand(string.Format("shell mkdir -p {0}/", DeviceExternalStorageSavedPath)); RunAdbDeviceCommand(string.Format("shell mkdir -p {0}/", DeviceExternalFilesSavedPath)); } // start the app on device! string CommandLine = "shell am start -W -S -n " + Install.AndroidPackageName + "/" + LaunchActivity; IProcessResult Process = RunAdbDeviceCommand(CommandLine, false, true); return new AndroidAppInstance(this, Install, Process); } #endregion public void PopulateDirectoryMappings(string ProjectDir) { LocalDirectoryMappings.Clear(); ProjectDir = ProjectDir.Replace('\\', '/'); if (!ProjectDir.EndsWith('/')) { ProjectDir += '/'; } LocalDirectoryMappings.Add(EIntendedBaseCopyDirectory.Build, ProjectDir + "Build"); LocalDirectoryMappings.Add(EIntendedBaseCopyDirectory.Binaries, ProjectDir + "Binaries"); LocalDirectoryMappings.Add(EIntendedBaseCopyDirectory.Config, ProjectDir + "Saved/Config"); LocalDirectoryMappings.Add(EIntendedBaseCopyDirectory.Content, ProjectDir + "Content"); LocalDirectoryMappings.Add(EIntendedBaseCopyDirectory.Demos, ProjectDir + "Saved/Demos"); LocalDirectoryMappings.Add(EIntendedBaseCopyDirectory.PersistentDownloadDir, ProjectDir + "Saved/PersistentDownloadDir"); LocalDirectoryMappings.Add(EIntendedBaseCopyDirectory.Profiling, ProjectDir + "Saved/Profiling"); LocalDirectoryMappings.Add(EIntendedBaseCopyDirectory.Saved, ProjectDir + "Saved"); } public static bool UsingAndroidFileServer(FileReference RawProjectPath, UnrealTargetConfiguration TargetConfiguration, out bool bEnablePlugin, out string AFSToken, out bool bIsShipping, out bool bIncludeInShipping, out bool bAllowExternalStartInShipping) { if(RawProjectPath == null || !FileReference.Exists(RawProjectPath) || TargetConfiguration == UnrealTargetConfiguration.Unknown) { bEnablePlugin = false; AFSToken = string.Empty; bIsShipping = false; bIncludeInShipping = false; bAllowExternalStartInShipping = false; return false; } UnrealTargetPlatform TargetPlatform = UnrealTargetPlatform.Android; bIsShipping = TargetConfiguration == UnrealTargetConfiguration.Shipping; ConfigHierarchy Ini = ConfigCache.ReadHierarchy(ConfigHierarchyType.Engine, DirectoryReference.FromFile(RawProjectPath), TargetPlatform); if (!Ini.GetBool("/Script/AndroidFileServerEditor.AndroidFileServerRuntimeSettings", "bEnablePlugin", out bEnablePlugin)) { bEnablePlugin = true; } if (!Ini.GetString("/Script/AndroidFileServerEditor.AndroidFileServerRuntimeSettings", "SecurityToken", out AFSToken)) { AFSToken = ""; } if (!Ini.GetBool("/Script/AndroidFileServerEditor.AndroidFileServerRuntimeSettings", "bIncludeInShipping", out bIncludeInShipping)) { bIncludeInShipping = false; } if (!Ini.GetBool("/Script/AndroidFileServerEditor.AndroidFileServerRuntimeSettings", "bAllowExternalStartInShipping", out bAllowExternalStartInShipping)) { bAllowExternalStartInShipping = false; } if (bIsShipping && !(bIncludeInShipping && bAllowExternalStartInShipping)) { return false; } return bEnablePlugin; } public bool UseAFS(UnrealAppConfig AppConfig) { AndroidBuild Build = AppConfig.Build as AndroidBuild; if (Build == null) { throw new AutomationException("Unsupported build type {0} for Android!", AppConfig.Build.GetType()); } return UsingAndroidFileServer(AppConfig.ProjectFile, Build.Configuration, out _, out _, out _, out _, out _); } /// /// Runs an ADB command, automatically adding the name of the current device to /// the arguments sent to adb /// /// /// /// /// /// public IProcessResult RunAdbDeviceCommand(string Args, bool Wait = true, bool bShouldLogCommand = false, bool bPauseErrorParsing = false, int TimeoutSec = 60 * 15) { if (string.IsNullOrEmpty(DeviceName) == false) { Args = string.Format("-s {0} {1}", DeviceName, Args); } return RunAdbGlobalCommand(Args, Wait, bShouldLogCommand, bPauseErrorParsing, TimeoutSec); } public IProcessResult RunAFSDeviceCommand(string Args, bool Wait = true, bool bShouldLogCommand = false, bool bPauseErrorParsing = false, int TimeoutSec = 60 * 15) { if (string.IsNullOrEmpty(DeviceName) == false) { Args = string.Format("-s {0} {1}", DeviceName, Args); } return RunAFSGlobalCommand(Args, Wait, bShouldLogCommand, bPauseErrorParsing, TimeoutSec); } /// /// Runs an ADB command, automatically adding the name of the current device to /// the arguments sent to adb /// /// /// public string RunAdbDeviceCommandAndGetOutput(string Args) { if (string.IsNullOrEmpty(DeviceName) == false) { Args = string.Format("-s {0} {1}", DeviceName, Args); } IProcessResult Result = RunAdbGlobalCommand(Args); if (Result.ExitCode != 0) { throw new DeviceException("adb command {0} failed. {1}", Args, Result.Output); } return Result.Output; } /// /// Runs an ADB command at the global scope /// /// /// /// /// /// public static IProcessResult RunAdbGlobalCommand(string Args, bool Wait = true, bool bShouldLogCommand = false, bool bPauseErrorParsing = false, int TimeoutSec = 60 * 15) { CommandUtils.ERunOptions RunOptions = CommandUtils.ERunOptions.AppMustExist | CommandUtils.ERunOptions.NoWaitForExit | CommandUtils.ERunOptions.SpewIsVerbose; RunOptions |= Log.IsVeryVerbose? ERunOptions.AllowSpew : ERunOptions.NoLoggingOfRunCommand; if (bShouldLogCommand) { Log.Verbose("Running ADB Command: adb {0}", Args); } IProcessResult Process; using (bPauseErrorParsing ? new ScopedSuspendECErrorParsing() : null) { Process = AndroidPlatform.RunAdbCommand(null, null, Args, null, RunOptions); if (Wait) { Process.ProcessObject.WaitForExit(TimeoutSec * 1000); if (!Process.HasExited) { Log.Info("ADB Command 'adb {Args}' timed out after {Timeout} sec.", Args, TimeoutSec); Process.ProcessObject?.Kill(); Process.ProcessObject?.WaitForExit(15000); } } } return Process; } /// /// Run Adb command without waiting for exit /// /// /// public ILongProcessResult RunAdbCommandNoWait(string Args, string LocalCache) { if (string.IsNullOrEmpty(DeviceName) == false) { Args = string.Format("-s {0} {1}", DeviceName, Args); } CommandUtils.ERunOptions RunOptions = CommandUtils.ERunOptions.AppMustExist | CommandUtils.ERunOptions.NoWaitForExit | CommandUtils.ERunOptions.SpewIsVerbose; RunOptions |= Log.IsVeryVerbose ? ERunOptions.AllowSpew : ERunOptions.NoLoggingOfRunCommand; string AdbCommand = Environment.ExpandEnvironmentVariables("%ANDROID_HOME%/platform-tools/adb" + (OperatingSystem.IsWindows() ? ".exe" : "")); return new LongProcessResult(AdbCommand, Args, RunOptions, LocalCache: LocalCache); } public static IProcessResult RunAFSGlobalCommand(string Args, bool Wait = true, bool bShouldLogCommand = false, bool bPauseErrorParsing = false, int TimeoutSec = 60 * 15 ) { CommandUtils.ERunOptions RunOptions = CommandUtils.ERunOptions.AppMustExist | CommandUtils.ERunOptions.NoWaitForExit | CommandUtils.ERunOptions.SpewIsVerbose; if (Log.IsVeryVerbose) { RunOptions |= CommandUtils.ERunOptions.AllowSpew; } else { RunOptions |= CommandUtils.ERunOptions.NoLoggingOfRunCommand; } if (bShouldLogCommand) { Log.Verbose("Running AFS Command: UnrealAndroidFileTool {0}", Args); } IProcessResult Process; using (bPauseErrorParsing ? new ScopedSuspendECErrorParsing() : null) { Process = AndroidPlatform.RunAFSCommand(null, null, Args, null, RunOptions); if (Wait) { Process.ProcessObject.WaitForExit(TimeoutSec * 1000); if (!Process.HasExited) { Log.Info("AFS Command 'UnrealAndroidFileTool {Args}' timed out after {Timeout} sec.", Args, TimeoutSec); Process.ProcessObject?.Kill(); Process.ProcessObject?.WaitForExit(15000); } } } return Process; } public static ITargetDevice[] GetDefaultDevices() { return GetAllAvailableDevices().Select(Device => new TargetDeviceAndroid(Device)).ToArray(); } public void PostRunCleanup(UnrealAppConfig AppConfig, string Package) { // Delete the commandline file, if someone installs an APK on top of ours // they will get very confusing behavior... if (string.IsNullOrEmpty(CommandLineFilePath) == false) { Log.Verbose("Removing {0}", CommandLineFilePath); if (UseAFS(AppConfig)) { DeleteFileFromDevice(Package, "^commandfile", AppConfig.ProjectFile, AppConfig.Configuration); } else { DeleteFileFromDevice(Package, CommandLineFilePath); } } } public string AFSGetFileInfo(string PackageName, string AFSToken, string FileName) { IProcessResult result; if (FileName.StartsWith("^")) { // Query to get which file this actually is. string ActualFileName = ""; result = RunAFSDeviceCommand(string.Format("-p \"{0}\" -k \"{1}\" query", PackageName, AFSToken)); string[] mappings = result.Output.Split(new[] { "\r\n" }, StringSplitOptions.RemoveEmptyEntries).Select(S => S.Trim()).ToArray(); foreach (string mapping in mappings) { string[] FromTo = mapping.Split(new[] { " " }, StringSplitOptions.RemoveEmptyEntries).Select(S => S.Trim()).ToArray(); if (FromTo.Length != 2) { continue; } if (FromTo[0] == FileName) { ActualFileName = Path.GetFileName(FromTo[1]); break; } } if (string.IsNullOrEmpty(ActualFileName)) { return ""; //throw new AutomationException("Failed to find file {0} in AFS mappings", FileName); } // Now List all the files in ^obb and find our file result = RunAFSDeviceCommand(string.Format("-p \"{0}\" -k \"{1}\" ls -ls \"^obb\"", PackageName, AFSToken)); string[] ObbFiles = result.Output.Split(new[] { "\r\n" }, StringSplitOptions.RemoveEmptyEntries).Select(S => S.Trim()).ToArray(); foreach (string file in ObbFiles) { if (file.Contains(ActualFileName)) { return file; } } // Now List all the files in ^project and find our file result = RunAFSDeviceCommand(string.Format("-p \"{0}\" -k \"{1}\" ls -ls \"^project\"", PackageName, AFSToken)); string[] ProjectFiles = result.Output.Split(new[] { "\r\n" }, StringSplitOptions.RemoveEmptyEntries).Select(S => S.Trim()).ToArray(); foreach (string file in ProjectFiles) { if (file.Contains(ActualFileName)) { return file; } } //throw new AutomationException("Failed to find file {0} in AFS ^obb", FileName); return ""; } else { //throw new AutomationException("Unsupported file {0} for AFS", FileName); return ""; } } public bool CopyFileToDevice(string PackageName, string SourcePath, string DestPath, FileReference ProjectFile = null, UnrealTargetConfiguration Configuration = UnrealTargetConfiguration.Unknown, bool IgnoreDependencies = false) { string AFSToken; bool UseAFS = UsingAndroidFileServer(ProjectFile, Configuration, out _, out AFSToken, out _, out _, out _); bool IsAPK = string.Equals(Path.GetExtension(SourcePath), ".apk", StringComparison.OrdinalIgnoreCase); // for the APK there's no easy/reliable way to get the date of the version installed, so // we write this out to a dependency file in the demote dir and check it each time. // current file time DateTime LocalModifiedTime = File.GetLastWriteTime(SourcePath); string QuotedSourcePath = SourcePath; if (SourcePath.Contains(" ")) { QuotedSourcePath = '"' + SourcePath + '"'; } // dependency info is a hash of the destination name, saved under a folder on /sdcard string DestHash = ContentHash.MD5(DestPath).ToString(); string DependencyCacheDir = "/sdcard/gdeps"; string DepFile = string.Format("{0}/{1}", DependencyCacheDir, DestHash); IProcessResult AdbResult = null; // get info from the device about this file string CurrentFileInfo = null; if (IsAPK) { // for APK query the package info and get the update time AdbResult = RunAdbDeviceCommand(string.Format("shell dumpsys package {0} | grep lastUpdateTime", PackageName)); if (AdbResult.ExitCode == 0) { CurrentFileInfo = AdbResult.Output.ToString().Trim(); } } else { // for other files get the file info if (UseAFS) { CurrentFileInfo = AFSGetFileInfo(PackageName, AFSToken, DestPath); } else { AdbResult = RunAdbDeviceCommand(string.Format("shell ls -l {0}", DestPath)); if (AdbResult.ExitCode == 0) { CurrentFileInfo = AdbResult.Output.ToString().Trim(); } } } bool SkipInstall = false; // If this is valid then there is some form of that file on the device, now figure out if it matches the if (string.IsNullOrEmpty(CurrentFileInfo) == false) { // read the dep file AdbResult = RunAdbDeviceCommand(string.Format("shell cat {0}", DepFile)); if (AdbResult.ExitCode == 0) { // Dependency info is the modified time of the source, and the post-copy file stats of the installed file, separated by ### string[] DepLines = AdbResult.Output.ToString().Split(new[] { "###" }, StringSplitOptions.RemoveEmptyEntries).Select(S => S.Trim()).ToArray(); if (DepLines.Length >= 2) { string InstalledSourceModifiedTime = DepLines[0]; string InstalledFileInfo = DepLines[1]; if (InstalledSourceModifiedTime == LocalModifiedTime.ToString() && CurrentFileInfo == InstalledFileInfo) { SkipInstall = true; } } } } if (SkipInstall && IgnoreDependencies == false) { Log.Info("Skipping install of {0} - remote file up to date", Path.GetFileName(SourcePath)); } else { if (IsAPK) { // we need to ununstall then install the apk - don't care if it fails, may have been deleted string AdbCommand = string.Format("uninstall {0}", PackageName); AdbResult = RunAdbDeviceCommand(AdbCommand); Log.Info("Installing {0} to {1}", SourcePath, Name); AdbCommand = string.Format("install {0}", QuotedSourcePath); AdbResult = RunAdbDeviceCommand(AdbCommand); if (AdbResult.ExitCode != 0) { throw new DeviceException("Failed to install {0}. Error {1}", SourcePath, AdbResult.Output); } // for APK query the package info and get the update time AdbResult = RunAdbDeviceCommand(string.Format("shell dumpsys package {0} | grep lastUpdateTime", PackageName)); CurrentFileInfo = AdbResult.Output.ToString().Trim(); } else { string FileDirectory = Path.GetDirectoryName(DestPath).Replace('\\', '/'); if (UseAFS) { Log.Info("Copying {0} to {1} via AFS", QuotedSourcePath, DestPath); AdbResult = RunAFSDeviceCommand(string.Format("-p \"{0}\" -k \"{1}\" push \"{2}\" \"{3}\"", PackageName, AFSToken, SourcePath, DestPath), TimeoutSec: MaxInstallTime); } else { RunAdbDeviceCommand(string.Format("shell mkdir -p {0}/", FileDirectory)); Log.Info("Copying {0} to {1} via adb push", QuotedSourcePath, DestPath); AdbResult = RunAdbDeviceCommand(string.Format("push {0} {1}", QuotedSourcePath, DestPath), TimeoutSec: MaxInstallTime); } // Note: Presently, AdbResult NEVER reports failures to push when using AFS. Should be fixed at some point. if (AdbResult.ExitCode != 0) { if ((AdbResult.Output.Contains("couldn't read from device") || AdbResult.Output.Contains("offline")) && !IsConnected) { Log.Info("Lost connection with device '{Name}'.", Name); // Disconnection occurred. Let's retry a second time before given up // Try to reconnect if (Connect()) { if (UseAFS) { Log.Info("Retrying to copy via AFS..."); AdbResult = RunAFSDeviceCommand(string.Format("-p {0} -k {1} push \"{2}\" \"{3}\"", PackageName, AFSToken, SourcePath, DestPath), TimeoutSec: MaxInstallTime); } else { Log.Info("Retrying to copy via adb push..."); AdbResult = RunAdbDeviceCommand(string.Format("push {0} {1}", QuotedSourcePath, DestPath), TimeoutSec: MaxInstallTime); } if (AdbResult.ExitCode != 0) { throw new DeviceException("Failed to push {0} to device. Error {1}", SourcePath, AdbResult.Output); } } else { throw new DeviceException("Failed to reconnect {0}", Name); } } else { throw new DeviceException("Failed to push {0} to device. Error {1}", SourcePath, AdbResult.Output); } } // Now pull info about the file which we'll write as a dep if (UseAFS) { CurrentFileInfo = AFSGetFileInfo(PackageName, AFSToken, DestPath); } else { AdbResult = RunAdbDeviceCommand(string.Format("shell ls -l {0}", DestPath)); CurrentFileInfo = AdbResult.Output.ToString().Trim(); } } // write the actual dependency info string DepContents = LocalModifiedTime + "###" + CurrentFileInfo; // save last modified time to remote deps after success RunAdbDeviceCommand(string.Format("shell mkdir -p {0}", DependencyCacheDir)); AdbResult = RunAdbDeviceCommand(string.Format("shell echo \"{0}\" > {1}", DepContents, DepFile)); if (AdbResult.ExitCode != 0) { Log.Warning(KnownLogEvents.Gauntlet_DeviceEvent, "Failed to write dependency file {File}", DepFile); } } return true; } public void AllowDeviceSleepState(bool bAllowSleep) { string CommandLine = "shell svc power stayon " + (bAllowSleep ? "false" : "usb"); RunAdbDeviceCommand(CommandLine, true, false, true); } /// /// Enable Android permissions which would otherwise block automation with permission requests /// public void EnablePermissions(string AndroidPackageName) { List Permissions = new List { "POST_NOTIFICATIONS", "WRITE_EXTERNAL_STORAGE", "READ_EXTERNAL_STORAGE", "GET_ACCOUNTS", "RECORD_AUDIO" }; Permissions.ForEach(Permission => { string CommandLine = string.Format("shell pm grant {0} android.permission.{1}", AndroidPackageName, Permission); Log.Verbose(string.Format("Enabling permission: {0} {1}", AndroidPackageName, Permission)); RunAdbDeviceCommand(CommandLine, true, false, true); }); Log.Verbose($"Enabling permission: {AndroidPackageName} MANAGE_EXTERNAL_STORAGE"); RunAdbDeviceCommand($"shell appops set {AndroidPackageName} MANAGE_EXTERNAL_STORAGE allow"); } public void KillRunningProcess(string AndroidPackageName) { Log.Verbose("{0}: Killing process '{1}' ", ToString(), AndroidPackageName); string KillProcessCommand = string.Format("shell am force-stop {0}", AndroidPackageName); RunAdbDeviceCommand(KillProcessCommand); } protected void CopyOBBFiles(UnrealAppConfig AppConfig, Dictionary OBBFiles, string Package) { bool bAFSEnablePlugin; string AFSToken = ""; bool bIsShipping; bool bAFSIncludeInShipping; bool bAFSAllowExternalStartInShipping; bool useADB = true; if (AppConfig != null) { AndroidBuild Build = AppConfig.Build as AndroidBuild; if (Build == null) { throw new AutomationException("Unsupported build type {0} for Android!", AppConfig.Build.GetType()); } if (UsingAndroidFileServer(AppConfig.ProjectFile, Build.Configuration, out bAFSEnablePlugin, out AFSToken, out bIsShipping, out bAFSIncludeInShipping, out bAFSAllowExternalStartInShipping)) { useADB = false; } } if (useADB) { string OBBRemoteDestination = string.Format("{0}/Android/obb/{1}", StoragePath, Package); // Remove all existing obbs RunAdbDeviceCommand(string.Format("shell rm {0}", OBBRemoteDestination)); RunAdbDeviceCommand(string.Format("shell mkdir -p {0}", OBBRemoteDestination)); } else { RunAFSDeviceCommand(string.Format("-p {0} -k {1} rm {2}", Package, AFSToken, "^mainobb")); RunAFSDeviceCommand(string.Format("-p {0} -k {1} rm {2}", Package, AFSToken, "^patchobb")); for(int i = 1; i <= 32; i++) { string overflowobb = string.Format("^overflow{0}obb", i); RunAFSDeviceCommand(string.Format("-p {0} -k {1} rm {2}", Package, AFSToken, overflowobb)); } } // obb files need to be named based on APK version (grrr), so find that out. This should return something like // versionCode=2 minSdk=21 targetSdk=21 string PackageInfo = RunAdbDeviceCommand(string.Format("shell dumpsys package {0} | grep versionCode", Package)).Output; var Match = Regex.Match(PackageInfo, @"versionCode=([\d\.]+)\s"); if (Match.Success == false) { throw new AutomationException("Failed to find version info for APK!"); } string PackageVersion = Match.Groups[1].ToString(); foreach (KeyValuePair Pair in OBBFiles) { string SourceFile = Pair.Key; string DestinationFile = Pair.Value; if (useADB) { // If we installed a new APK we need to change the package version Match OBBMatch = Regex.Match(SourceFile, @"\.(\d+)\.com.*\.obb"); if (OBBMatch.Success) { string StrippedObb = Path.GetFileName(SourceFile.Replace(".Client.obb", ".obb").Replace(OBBMatch.Groups[1].ToString(), PackageVersion)); DestinationFile = StoragePath + "/Android/obb/" + Package + "/" + StrippedObb; } DestinationFile = Regex.Replace(DestinationFile, "%STORAGE%", StoragePath, RegexOptions.IgnoreCase); CopyFileToDevice(null, SourceFile, DestinationFile, AppConfig.ProjectFile, AppConfig.Configuration); } else { string Filename = Path.GetFileName(SourceFile); string[] parts = Filename.Split(new[] { "." }, StringSplitOptions.RemoveEmptyEntries).Select(S => S.Trim()).ToArray(); // Make sure last part is obb if (parts[parts.Length - 1] == "obb") { string obbname = string.Format("^{0}obb", parts[0]); CopyFileToDevice(Package, SourceFile, obbname, AppConfig.ProjectFile, AppConfig.Configuration); } else { DestinationFile = Regex.Replace(DestinationFile, "%STORAGE%", StoragePath, RegexOptions.IgnoreCase); CopyFileToDevice(Package, SourceFile, DestinationFile, AppConfig.ProjectFile, AppConfig.Configuration); } } } } protected void CopyCommandlineFile(UnrealAppConfig AppConfig, string Commandline, string Package, string Project, bool bUseExternalFileDirectory) { string ExternalStoragePath = StoragePath + "/UnrealGame/" + Project; string ExternalFilesPath = StoragePath + "/Android/data/" + Package + "/files/UnrealGame/" + Project; string DeviceBaseDir = bUseExternalFileDirectory ? ExternalFilesPath : ExternalStoragePath; CommandLineFilePath = string.Format("{0}/UECommandLine.txt", DeviceBaseDir); PostRunCleanup(AppConfig, Package); // function needs a rename in this context, just delete any old UECommandlines // Create a tempfile, insert the command line, and push it over string CommandLineTmpFile = Path.GetTempFileName(); // I've seen a weird thing where adb push truncates by a byte, so add some padding... File.WriteAllText(CommandLineTmpFile, Commandline + " "); if (UseAFS(AppConfig)) { CopyFileToDevice(Package, CommandLineTmpFile, "^commandfile", AppConfig.ProjectFile, AppConfig.Configuration); } else { CopyFileToDevice(Package, CommandLineTmpFile, CommandLineFilePath); } File.Delete(CommandLineTmpFile); } protected bool DeleteFileFromDevice(string Package, string DestPath, FileReference ProjectFile = null, UnrealTargetConfiguration Configuration = UnrealTargetConfiguration.Unknown) { string AFSToken; bool UseAFS = UsingAndroidFileServer(ProjectFile, Configuration, out _, out AFSToken, out _, out _, out _); IProcessResult AdbResult; if (UseAFS) { AdbResult = RunAFSDeviceCommand(string.Format("-p \"{0}\" -k \"{1}\" rm \"{2}\"", Package, AFSToken, DestPath)); } else { AdbResult = RunAdbDeviceCommand(string.Format("shell rm -f {0}", DestPath)); } return AdbResult.ExitCode == 0; } /// /// Returns a list of locally connected devices (e.g. 'adb devices'). /// /// private static Dictionary GetAllConnectedDevices() { var Result = RunAdbGlobalCommand("devices"); MatchCollection DeviceMatches = Regex.Matches(Result.Output, @"^([\d\w\.\:\-]{6,32})\s+(\w+)", RegexOptions.Multiline); var DeviceList = DeviceMatches.Cast().ToDictionary( M => M.Groups[1].ToString(), M => M.Groups[2].ToString().ToLower() ); return DeviceList; } private static IEnumerable GetAllAvailableDevices() { var AllDevices = GetAllConnectedDevices(); return AllDevices.Keys.Where(D => AllDevices[D] == "device"); } #region Legacy Implementations public IAppInstall InstallApplication(UnrealAppConfig AppConfig) { // todo - pass this through AndroidBuild Build = AppConfig.Build as AndroidBuild; // Ensure APK exists if (Build == null) { throw new AutomationException("Invalid build for Android!"); } bool bAFSEnablePlugin; string AFSToken = ""; bool bIsShipping; bool bAFSIncludeInShipping; bool bAFSAllowExternalStartInShipping; bool useADB = true; if (AppConfig != null) { if (UsingAndroidFileServer(AppConfig.ProjectFile, Build.Configuration, out bAFSEnablePlugin, out AFSToken, out bIsShipping, out bAFSIncludeInShipping, out bAFSAllowExternalStartInShipping)) { useADB = false; } } // kill any currently running instance: KillRunningProcess(Build.AndroidPackageName); // "/mnt/sdcard"; // remote dir used to save things string ExternalStoragePath = StoragePath + "/UnrealGame/" + AppConfig.ProjectName; string ExternalFilesPath = StoragePath + "/Android/data/" + Build.AndroidPackageName + "/files/UnrealGame/" + AppConfig.ProjectName; string DeviceBaseDir = Build.UsesExternalFilesDir ? ExternalFilesPath : ExternalStoragePath; // path to the APK to install. string ApkPath = Build.SourceApkPath; // get the device's external file paths, always clear between runs DeviceExternalStorageSavedPath = string.Format("{0}/{1}/Saved", ExternalStoragePath, AppConfig.ProjectName); DeviceExternalFilesSavedPath = string.Format("{0}/{1}/Saved", ExternalFilesPath, AppConfig.ProjectName); DeviceLogPath = string.Format("{0}/Logs/{1}.log", Build.UsesPublicLogs ? DeviceExternalFilesSavedPath : DeviceExternalStorageSavedPath, AppConfig.ProjectName); DeviceArtifactPath = Build.UsesExternalFilesDir ? DeviceExternalFilesSavedPath : DeviceExternalStorageSavedPath; // path for OBB files string OBBRemoteDestination = string.Format("{0}/Android/obb/{1}", StoragePath, Build.AndroidPackageName); Log.Info("DeviceBaseDir: " + DeviceBaseDir); Log.Info("DeviceExternalStorageSavedPath: " + DeviceExternalStorageSavedPath); Log.Info("DeviceExternalFilesSavedPath: " + DeviceExternalFilesSavedPath); Log.Info("DeviceLogPath: " + DeviceLogPath); Log.Info("DeviceArtifactPath: " + DeviceArtifactPath); // clear all file store paths between installs: RunAdbDeviceCommand(string.Format("shell rm -r {0}", DeviceExternalStorageSavedPath)); RunAdbDeviceCommand(string.Format("shell rm -r {0}", DeviceExternalFilesSavedPath)); if (AppConfig.FullClean) { Log.Info("Fully cleaning console before install..."); RunAdbDeviceCommand(string.Format("shell rm -r {0}/UnrealGame/*", StoragePath)); RunAdbDeviceCommand(string.Format("shell rm -r {0}/Android/data/{1}/files/*", StoragePath, Build.AndroidPackageName)); RunAdbDeviceCommand(string.Format("shell rm -r {0}/obb/{1}/*", StoragePath, Build.AndroidPackageName)); RunAdbDeviceCommand(string.Format("shell rm -r {0}/Android/obb/{1}/*", StoragePath, Build.AndroidPackageName)); RunAdbDeviceCommand(string.Format("shell rm -r {0}/Download/*", StoragePath)); } if (!AppConfig.SkipInstall) { if (Globals.Params.ParseParam("cleandevice") || AppConfig.FullClean) { Log.Info("Cleaning previous builds due to presence of -cleandevice"); // we need to ununstall then install the apk - don't care if it fails, may have been deleted Log.Info("Uninstalling {0}", Build.AndroidPackageName); RunAdbDeviceCommand(string.Format("uninstall {0}", Build.AndroidPackageName)); // delete DeviceExternalStorageSavedPath, note: DeviceExternalFilesSavedPath is removed with package uninstall. Log.Info("Removing {0}", DeviceExternalStorageSavedPath); RunAdbDeviceCommand(string.Format("shell rm -r {0}", DeviceExternalStorageSavedPath)); Log.Info("Removing {0}", OBBRemoteDestination); RunAdbDeviceCommand(string.Format("shell rm -r {0}", OBBRemoteDestination)); } // check for a local newer executable if (Globals.Params.ParseParam("dev")) { //string ApkFileName = Path.GetFileName(ApkPath); string ApkFileName2 = UnrealHelpers.GetExecutableName(AppConfig.ProjectName, UnrealTargetPlatform.Android, AppConfig.Configuration, AppConfig.ProcessType, "apk"); string LocalAPK = Path.Combine(Environment.CurrentDirectory, AppConfig.ProjectName, "Binaries/Android", ApkFileName2); bool LocalFileExists = File.Exists(LocalAPK); bool LocalFileNewer = LocalFileExists && File.GetLastWriteTime(LocalAPK) > File.GetLastWriteTime(ApkPath); Log.Verbose("Checking for newer binary at {0}", LocalAPK); Log.Verbose("LocalFile exists: {0}. Newer: {1}", LocalFileExists, LocalFileNewer); if (LocalFileExists && LocalFileNewer) { ApkPath = LocalAPK; } } bool NoPlayProtect = Globals.Params.ParseParam("no-play-protect"); if (NoPlayProtect) { NoPlayProtectSetting = RunAdbDeviceCommandAndGetOutput("shell settings get global package_verifier_user_consent"); if (NoPlayProtectSetting != "-1") { Log.Verbose("Removing play protect for this session..."); RunAdbDeviceCommand("shell settings put global package_verifier_user_consent -1"); } } // first install the APK CopyFileToDevice(Build.AndroidPackageName, ApkPath, string.Empty, AppConfig.ProjectFile, AppConfig.Configuration); // remote dir on the device, create it if it doesn't exist if (useADB) { RunAdbDeviceCommand(string.Format("shell mkdir -p {0}/", DeviceExternalStorageSavedPath)); RunAdbDeviceCommand(string.Format("shell mkdir -p {0}/", DeviceExternalFilesSavedPath)); } else { RunAFSDeviceCommand(string.Format("-p {0} -k {1} mkdir \"^saved\"", Build.AndroidPackageName, AFSToken)); } } // Convert the files from the source to final destination names Dictionary FilesToInstall = new Dictionary(); Console.WriteLine("trying to copy files over."); if (AppConfig.FilesToCopy != null) { if (LocalDirectoryMappings.Count == 0) { Console.WriteLine("Populating Directory"); PopulateDirectoryMappings(Path.GetDirectoryName(DeviceArtifactPath)); } foreach (UnrealFileToCopy FileToCopy in AppConfig.FilesToCopy) { string PathToCopyTo = Path.Combine(LocalDirectoryMappings[FileToCopy.TargetBaseDirectory], FileToCopy.TargetRelativeLocation); if (File.Exists(FileToCopy.SourceFileLocation)) { FileInfo SrcInfo = new FileInfo(FileToCopy.SourceFileLocation); SrcInfo.IsReadOnly = false; FilesToInstall.Add(FileToCopy.SourceFileLocation, PathToCopyTo.Replace("\\", "/")); } else { Log.Warning(KnownLogEvents.Gauntlet_DeviceEvent, "File to copy {File} not found", FileToCopy); } } } if (!AppConfig.SkipInstall) { // obb files need to be named based on APK version (grrr), so find that out. This should return something like // versionCode=2 minSdk=21 targetSdk=21 string PackageInfo = RunAdbDeviceCommand(string.Format("shell dumpsys package {0} | grep versionCode", Build.AndroidPackageName)).Output; var Match = Regex.Match(PackageInfo, @"versionCode=([\d\.]+)\s"); if (Match.Success == false) { throw new AutomationException("Failed to find version info for APK!"); } string PackageVersion = Match.Groups[1].ToString(); Build.FilesToInstall.Keys.ToList().ForEach(K => { string SrcPath = K; string SrcFile = Path.GetFileName(SrcPath); string DestPath = Build.FilesToInstall[K]; string DestFile = Path.GetFileName(DestPath); // If we installed a new APK we need to change the package version Match OBBMatch = Regex.Match(SrcFile, @"\.(\d+)\.com.*\.obb"); if (OBBMatch.Success) { DestPath = StoragePath + "/Android/obb/" + Build.AndroidPackageName + "/" + SrcFile.Replace(".Client.obb", ".obb").Replace(OBBMatch.Groups[1].ToString(), PackageVersion); } DestPath = Regex.Replace(DestPath, "%STORAGE%", StoragePath, RegexOptions.IgnoreCase); FilesToInstall.Add(SrcPath, DestPath); }); // get a list of files in the destination OBB directory IProcessResult AdbResult; AdbResult = RunAdbDeviceCommand(string.Format("shell ls {0}", OBBRemoteDestination)); // if != 0 then no folder exists if (AdbResult.ExitCode == 0) { string[] Delimiters = { "\r\n", "\n" }; string[] CurrentRemoteFileList = AdbResult.Output.Split(Delimiters, StringSplitOptions.RemoveEmptyEntries); for (int i = 0; i < CurrentRemoteFileList.Length; ++i) { CurrentRemoteFileList[i] = CurrentRemoteFileList[i].Trim(); } IEnumerable NewRemoteFileList = FilesToInstall.Values.Select(F => Path.GetFileName(F)); // delete any files that should not be there foreach (string FileName in CurrentRemoteFileList) { if (FileName.StartsWith(".") || FileName.Length == 0) { continue; } if (NewRemoteFileList.Contains(FileName) == false) { RunAdbDeviceCommand(string.Format("shell rm {0}/{1}", OBBRemoteDestination, FileName)); } } } EnablePermissions(Build.AndroidPackageName); // Copy other file dependencies (including OBB files) foreach (var KV in FilesToInstall) { string LocalFile = KV.Key; string RemoteFile = KV.Value; Console.WriteLine("Copying {0} to {1}", LocalFile, RemoteFile); if (useADB) { CopyFileToDevice(Build.AndroidPackageName, LocalFile, RemoteFile, AppConfig.ProjectFile, AppConfig.Configuration); } else { string Filename = Path.GetFileName(LocalFile); string[] parts = Filename.ToString().Split(new[] { "." }, StringSplitOptions.RemoveEmptyEntries).Select(S => S.Trim()).ToArray(); // Make sure last part is obb if (parts[parts.Length - 1] == "obb") { string obbname = string.Format("^{0}obb", parts[0]); CopyFileToDevice(Build.AndroidPackageName, LocalFile, obbname, AppConfig.ProjectFile, AppConfig.Configuration); } else { CopyFileToDevice(Build.AndroidPackageName, LocalFile, RemoteFile, AppConfig.ProjectFile, AppConfig.Configuration); } } } } else { Log.Info("Skipping install of {0} (-skipdeploy)", Build.AndroidPackageName); } CopyCommandlineFile(AppConfig, AppConfig.CommandLine, Build.AndroidPackageName, AppConfig.ProjectName, Build.UsesExternalFilesDir); AndroidAppInstall AppInstall = new AndroidAppInstall(AppConfig, this, InApkPath: ApkPath, AppConfig.ProjectName, Build.AndroidPackageName, AppConfig.CommandLine, AppConfig.ProjectFile, Build.Configuration); return AppInstall; } #endregion } class AndroidAppInstall : IAppInstall { public string Name { get; protected set; } public TargetDeviceAndroid AndroidDevice { get; protected set; } public ITargetDevice Device => AndroidDevice; public string AndroidPackageName { get; protected set; } public string CommandLine { get; protected set; } public string AppTag { get; set; } public string ApkPath { get; protected set; } public FileReference ProjectFile { get; protected set; } public UnrealTargetConfiguration Configuration { get; protected set; } public UnrealAppConfig AppConfig { get; protected set; } public AndroidAppInstall(UnrealAppConfig InAppConfig, TargetDeviceAndroid InDevice, string InName, string InAndroidPackageName, string InCommandLine, FileReference InProjectFile, UnrealTargetConfiguration InConfiguration, string InAppTag = "UE") { AppConfig = InAppConfig; AndroidDevice = InDevice; Name = InName; AndroidPackageName = InAndroidPackageName; CommandLine = InCommandLine; AppTag = InAppTag; ProjectFile = InProjectFile; Configuration = InConfiguration; } public AndroidAppInstall(UnrealAppConfig InAppConfig, TargetDeviceAndroid InDevice, string InApkPath, string InName, string InAndroidPackageName, string InCommandLine, FileReference InProjectFile, UnrealTargetConfiguration InConfiguration, string InAppTag = "UE") { AppConfig = InAppConfig; AndroidDevice = InDevice; ApkPath = InApkPath; Name = InName; AndroidPackageName = InAndroidPackageName; CommandLine = InCommandLine; AppTag = InAppTag; ProjectFile = InProjectFile; Configuration = InConfiguration; } public IAppInstance Run() { return AndroidDevice.Run(this); } } public class DefaultAndroidDevices : IDefaultDeviceSource { public bool CanSupportPlatform(UnrealTargetPlatform? Platform) { return Platform == UnrealTargetPlatform.Android; } public ITargetDevice[] GetDefaultDevices() { return TargetDeviceAndroid.GetDefaultDevices(); } } // become IAppInstance when implemented enough class AndroidAppInstance : IAppInstance { public string ArtifactPath { get { if (HasExited && !bHaveSavedArtifacts) { bHaveSavedArtifacts = true; SaveArtifacts(); } return Path.Combine(AndroidDevice.LocalCachePath, "Saved"); } } public bool HasExited { get { try { if (!LaunchProcess.HasExited) { return false; } } catch (InvalidOperationException) { return true; } return !IsActivityRunning(); } } public string StdOut { get { return LogProcess.Output; } } public ILogStreamReader GetLogReader() => LogProcess.GetLogReader(); public ILogStreamReader GetLogBufferReader() => LogProcess.GetLogBufferReader(); public bool WriteOutputToFile(string FilePath) => LogProcess.WriteOutputToFile(FilePath) != null; public string CommandLine => Install.CommandLine; public ITargetDevice Device => AndroidDevice; public int ExitCode => LaunchProcess.ExitCode; public bool WasKilled { get; protected set; } public IProcessResult LaunchProcess; protected ILongProcessResult LogProcess; protected TargetDeviceAndroid AndroidDevice; protected AndroidAppInstall Install; protected bool bHaveSavedArtifacts; private bool ActivityExited = false; public AdbScreenRecorder Recorder; public AndroidAppInstance(TargetDeviceAndroid InDevice, AndroidAppInstall InInstall, IProcessResult InProcess) { AndroidDevice = InDevice; Install = InInstall; LaunchProcess = InProcess; LogProcess = AndroidDevice.RunAdbCommandNoWait($"logcat -s {Install.AppTag} debug Debug DEBUG -v raw", Install.Device.LocalCachePath); if (Globals.Params.ParseParam("screenrecord")) { StartRecording(); } } public int WaitForExit() { if (!HasExited) { LaunchProcess.WaitForExit(); LogProcess.StopProcess(); } return ExitCode; } public void Kill(bool GenerateDump) { if (!HasExited && !AndroidDevice.Disposed) { WasKilled = true; Install.AndroidDevice.KillRunningProcess(Install.AndroidPackageName); LogProcess.StopProcess(); StopRecording(); } } public void StartRecording() { // Ensure artifact directories exist if (TargetDeviceAndroid.UsingAndroidFileServer(Install.ProjectFile, Install.Configuration, out _, out string AFSToken, out _, out _, out _)) { Install.AndroidDevice.RunAFSDeviceCommand(string.Format("-p \"{0}\" -k \"{1}\" mkdir \"^saved/Logs\"", Install.AndroidPackageName, AFSToken)); } else { Install.AndroidDevice.RunAdbDeviceCommand($"shell mkdir -p {Install.AndroidDevice.DeviceArtifactPath}/Logs/"); } Recorder = new AdbScreenRecorder(); Recorder.StartRecording(Install.AndroidDevice.DeviceName, $"{Install.AndroidDevice.DeviceArtifactPath}/Logs/screen_recording.mp4"); } public void StopRecording() { if (Recorder != null) { Recorder.StopRecording(); Recorder.Dispose(); Recorder = null; } } protected void SaveArtifacts() { StopRecording(); // Pull all the artifacts string ArtifactPullCommand = string.Format("pull {0} \"{1}\"", Install.AndroidDevice.DeviceArtifactPath, Install.AndroidDevice.LocalCachePath); IProcessResult PullCmd = Install.AndroidDevice.RunAdbDeviceCommand(ArtifactPullCommand, bShouldLogCommand: Log.IsVerbose); if (PullCmd.ExitCode != 0) { Log.Warning(KnownLogEvents.Gauntlet_DeviceEvent, "Failed to retrieve artifacts. {Output}", PullCmd.Output); } // pull the logcat over from device. IProcessResult LogcatResult = Install.AndroidDevice.RunAdbDeviceCommand("logcat -d", bShouldLogCommand: Log.IsVerbose); string SavedDirectory = Path.Combine(Install.AndroidDevice.LocalCachePath, "Saved"); string LogcatFilename = "Logcat.log"; // Save logcat dump to local artifact path. if (!Directory.Exists(SavedDirectory)) { Directory.CreateDirectory(SavedDirectory); } File.WriteAllText(Path.Combine(SavedDirectory, LogcatFilename), LogcatResult.Output); bHaveSavedArtifacts = true; } /// /// Checks on device whether the activity is running /// private bool IsActivityRunning() { if (ActivityExited || AndroidDevice.Disposed) { return false; } // get activities filtered by our package name IProcessResult ActivityQuery = AndroidDevice.RunAdbDeviceCommand("shell dumpsys activity -p " + Install.AndroidPackageName + " a"); // We have exited if our activity doesn't appear in the activity query or is not the focused activity. bool bActivityPresent = ActivityQuery.Output.Contains(Install.AndroidPackageName); bool bActivityInForeground = ActivityQuery.Output.Contains("ResumedActivity"); bool bHasExited = !bActivityPresent || !bActivityInForeground; if (bHasExited) { ActivityExited = true; if (!LogProcess.HasExited) { // The activity has exited, make sure entire activity log has been captured, sleep to allow time for the log to flush Thread.Sleep(5000); LogProcess.StopProcess(); } Log.VeryVerbose("{0}: process exited, Activity running={1}, Activity in foreground={2} ", ToString(), bActivityPresent.ToString(), bActivityInForeground.ToString()); } return !bHasExited; } } public class AdbScreenRecorder : IDisposable { private Process ADBProcess; private bool Disposed; public void StartRecording(string Device, string OutputFilePath) { if (ADBProcess != null && !ADBProcess.HasExited) { throw new InvalidOperationException("A screen recording session is already in progress."); } string AdbCommand = Environment.ExpandEnvironmentVariables("%ANDROID_HOME%/platform-tools/adb" + (OperatingSystem.IsWindows() ? ".exe" : "")); ADBProcess = new Process { StartInfo = new ProcessStartInfo { FileName = AdbCommand, Arguments = $"-s {Device} shell screenrecord {OutputFilePath}", RedirectStandardOutput = true, RedirectStandardInput = true, UseShellExecute = false, CreateNoWindow = true } }; ADBProcess.Start(); } public void StopRecording(int timeout = 500) { if (ADBProcess != null && !ADBProcess.HasExited) { try { ADBProcess.StandardInput.WriteLine("\x03"); // Send Ctrl+C to stop the recording if (!ADBProcess.WaitForExit(timeout)) { // Process did not exit within the specified timeout KillAdbProcess(); } } catch (InvalidOperationException) { // Handle the exception if StandardInput is not available KillAdbProcess(); } } } private void KillAdbProcess() { try { if (ADBProcess != null && !ADBProcess.HasExited) { ADBProcess.Kill(); } } catch (InvalidOperationException) { // Process has already exited, do nothing } } public void Dispose() { Dispose(true); GC.SuppressFinalize(this); } protected virtual void Dispose(bool disposing) { if (!Disposed) { if (disposing) { // Release managed resources StopRecording(); } // Release unmanaged resources if (ADBProcess != null) { ADBProcess.Dispose(); ADBProcess = null; } Disposed = true; } } ~AdbScreenRecorder() { Dispose(false); } } // Device data from json public sealed class AndroidDeviceData { // Host of PC which is tethered public string HostIP { get; set; } // public key public string PublicKey { get; set; } // private key public string PrivateKey { get; set; } } public class AndroidBuildSupport : BaseBuildSupport { protected override BuildFlags SupportedBuildTypes => BuildFlags.Packaged | BuildFlags.CanReplaceCommandLine | BuildFlags.CanReplaceExecutable | BuildFlags.Bulk | BuildFlags.NotBulk; protected override UnrealTargetPlatform? Platform => UnrealTargetPlatform.Android; } public class AndroidDeviceFactory : IDeviceFactory { public bool CanSupportPlatform(UnrealTargetPlatform? Platform) { return Platform == UnrealTargetPlatform.Android; } public ITargetDevice CreateDevice(string InRef, string InCachePath, string InParam = null) { AndroidDeviceData DeviceData = null; if (!string.IsNullOrEmpty(InParam)) { DeviceData = fastJSON.JSON.Instance.ToObject(InParam); } return new TargetDeviceAndroid(InRef, DeviceData, InCachePath); } } /// /// ADB key credentials, running adb-server commands (must) use same pub/private key store /// internal static class AdbCredentialCache { private static int InstanceCount = 0; private static bool bUsingCustomKeys = false; private static string PrivateKey; private static string PublicKey; private const string KeyBackupExt = ".gauntlet.bak"; static AdbCredentialCache() { Reset(); } public static void RemoveInstance() { lock (Globals.MainLock) { InstanceCount--; if (InstanceCount == 0 && bUsingCustomKeys) { Reset(); KillAdbServer(); // Kill ADB server, just as a safety measure to ensure it closes IEnumerable ADBProcesses = Process.GetProcesses().Where(p => p.ProcessName.Equals("adb")); if (ADBProcesses.Count() > 0) { Log.Info("Terminating {0} ADB Process(es)", ADBProcesses.Count()); foreach (Process ADBProcess in ADBProcesses) { Log.Info("Killing ADB process {0}", ADBProcess.Id); ADBProcess.Kill(); } } } } } public static void RestoreBackupKeys() { string LocalKeyPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".android"); string LocalKeyFile = Path.Combine(LocalKeyPath, "adbkey"); string LocalPubKeyFile = Path.Combine(LocalKeyPath, "adbkey.pub"); string BackupSentry = Path.Combine(LocalKeyPath, "gauntlet.inuse"); if (File.Exists(BackupSentry)) { Log.Info("Restoring original adb keys"); if (File.Exists(LocalKeyFile + KeyBackupExt)) { File.Copy(LocalKeyFile + KeyBackupExt, LocalKeyFile, true); File.Delete(LocalKeyFile + KeyBackupExt); } else { File.Delete(LocalKeyFile); } if (File.Exists(LocalPubKeyFile + KeyBackupExt)) { File.Copy(LocalPubKeyFile + KeyBackupExt, LocalPubKeyFile, true); File.Delete(LocalPubKeyFile + KeyBackupExt); } else { File.Delete(LocalPubKeyFile); } File.Delete(BackupSentry); } } public static void AddInstance(AndroidDeviceData DeviceData = null) { lock (Globals.MainLock) { string KeyPath = Globals.Params.ParseValue("adbkeys", null); // setup key store from device data if (string.IsNullOrEmpty(KeyPath) && DeviceData != null) { // checked that cached keys are the same if (!string.IsNullOrEmpty(PrivateKey)) { if (PrivateKey != DeviceData.PrivateKey) { throw new AutomationException("ADB device private keys must match"); } } if (!string.IsNullOrEmpty(PublicKey)) { if (PublicKey != DeviceData.PublicKey) { throw new AutomationException("ADB device public keys must match"); } } PrivateKey = DeviceData.PrivateKey; PublicKey = DeviceData.PublicKey; if (string.IsNullOrEmpty(PublicKey) || string.IsNullOrEmpty(PrivateKey)) { throw new AutomationException("Invalid key in device data"); } KeyPath = Path.Combine(Globals.TempDir, "AndroidADBKeys"); if (!Directory.Exists(KeyPath)) { Directory.CreateDirectory(KeyPath); } if (InstanceCount == 0) { byte[] data = Convert.FromBase64String(PrivateKey); File.WriteAllText(KeyPath + "/adbkey", Encoding.UTF8.GetString(data)); data = Convert.FromBase64String(PublicKey); File.WriteAllText(KeyPath + "/adbkey.pub", Encoding.UTF8.GetString(data)); } } if (InstanceCount == 0 && !String.IsNullOrEmpty(KeyPath)) { Log.Info("Using adb keys at {0}", KeyPath); string LocalKeyPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".android"); if(!Directory.Exists(LocalKeyPath)) { Directory.CreateDirectory(LocalKeyPath); } string RemoteKeyFile = Path.Combine(KeyPath, "adbkey"); string RemotePubKeyFile = Path.Combine(KeyPath, "adbkey.pub"); string LocalKeyFile = Path.Combine(LocalKeyPath, "adbkey"); string LocalPubKeyFile = Path.Combine(LocalKeyPath, "adbkey.pub"); string BackupSentry = Path.Combine(LocalKeyPath, "gauntlet.inuse"); if (File.Exists(RemoteKeyFile) == false) { throw new AutomationException("adbkey at {0} does not exist", KeyPath); } if (File.Exists(RemotePubKeyFile) == false) { throw new AutomationException("adbkey.pub at {0} does not exist", KeyPath); } if (File.Exists(BackupSentry) == false) { if (File.Exists(LocalKeyFile)) { File.Copy(LocalKeyFile, LocalKeyFile + KeyBackupExt, true); } if (File.Exists(LocalPubKeyFile)) { File.Copy(LocalPubKeyFile, LocalPubKeyFile + KeyBackupExt, true); } File.WriteAllText(BackupSentry, "placeholder"); } File.Copy(RemoteKeyFile, LocalKeyFile, true); File.Copy(RemotePubKeyFile, LocalPubKeyFile, true); bUsingCustomKeys = true; KillAdbServer(); } InstanceCount++; } } private static void Reset() { if (InstanceCount != 0) { throw new AutomationException("AdbCredentialCache.Reset() called with outstanding instances"); } PrivateKey = PublicKey = string.Empty; bUsingCustomKeys = false; RestoreBackupKeys(); } private static void KillAdbServer() { using (new ScopedSuspendECErrorParsing()) { Log.Info("Running adb kill-server to refresh credentials"); TargetDeviceAndroid.RunAdbGlobalCommand("kill-server"); // Killing the adb server restarts it and can surface superfluous device errors int SleepTime = CommandUtils.IsBuildMachine ? 15000 : 5000; Thread.Sleep(SleepTime); } } } class AndroidPlatformSupport : TargetPlatformSupportBase { public override UnrealTargetPlatform? Platform => UnrealTargetPlatform.Android; public override bool IsHostMountingSupported() => false; } }