diff --git a/dotnet-devices.Tests/Android/VirtualDeviceConfigTests.cs b/dotnet-devices.Tests/Android/VirtualDeviceConfigTests.cs index 43fc1ef..52209ce 100644 --- a/dotnet-devices.Tests/Android/VirtualDeviceConfigTests.cs +++ b/dotnet-devices.Tests/Android/VirtualDeviceConfigTests.cs @@ -1,4 +1,5 @@ using DotNetDevices.Android; +using System; using System.Threading.Tasks; using Xunit; @@ -21,6 +22,71 @@ namespace DotNetDevices.Tests Assert.Equal(id, device.Id); Assert.Equal(name, device.Name); } + + [Fact] + public async Task CanReadTV() + { + var config = new VirtualDeviceConfig("TestData/Android/AvdConfigIni_TV.txt"); + + var device = await config.CreateVirtualDeviceAsync(); + + Assert.Equal(VirtualDeviceType.TV, device.Type); + Assert.Equal(new Version(10, 0), device.Version); + Assert.Equal(29, device.ApiLevel); + Assert.Equal(VirtualDeviceRuntime.AndroidTV, device.Runtime); + } + + [Fact] + public async Task CanReadWear() + { + var config = new VirtualDeviceConfig("TestData/Android/AvdConfigIni_Wear.txt"); + + var device = await config.CreateVirtualDeviceAsync(); + + Assert.Equal(VirtualDeviceType.Wearable, device.Type); + Assert.Equal(new Version(9, 0), device.Version); + Assert.Equal(28, device.ApiLevel); + Assert.Equal(VirtualDeviceRuntime.AndroidWear, device.Runtime); + } + + [Fact] + public async Task CanReadTablet() + { + var config = new VirtualDeviceConfig("TestData/Android/AvdConfigIni_Tablet.txt"); + + var device = await config.CreateVirtualDeviceAsync(); + + Assert.Equal(VirtualDeviceType.Tablet, device.Type); + Assert.Equal(new Version(9, 0), device.Version); + Assert.Equal(28, device.ApiLevel); + Assert.Equal(VirtualDeviceRuntime.Android, device.Runtime); + } + + [Fact] + public async Task CanReadGeneric() + { + var config = new VirtualDeviceConfig("TestData/Android/AvdConfigIni_Generic.txt"); + + var device = await config.CreateVirtualDeviceAsync(); + + Assert.Equal(VirtualDeviceType.Phone, device.Type); + Assert.Equal(new Version(9, 0), device.Version); + Assert.Equal(28, device.ApiLevel); + Assert.Equal(VirtualDeviceRuntime.Android, device.Runtime); + } + + [Fact] + public async Task CanReadPhone() + { + var config = new VirtualDeviceConfig("TestData/Android/AvdConfigIni_Phone.txt"); + + var device = await config.CreateVirtualDeviceAsync(); + + Assert.Equal(VirtualDeviceType.Phone, device.Type); + Assert.Equal(new Version(10, 0), device.Version); + Assert.Equal(29, device.ApiLevel); + Assert.Equal(VirtualDeviceRuntime.Android, device.Runtime); + } } } } diff --git a/dotnet-devices.Tests/TestData/Android/AvdConfigIni_Generic.txt b/dotnet-devices.Tests/TestData/Android/AvdConfigIni_Generic.txt new file mode 100644 index 0000000..b29aea2 --- /dev/null +++ b/dotnet-devices.Tests/TestData/Android/AvdConfigIni_Generic.txt @@ -0,0 +1,40 @@ +AvdId=phone_xh-dpi_4_7in_pie_9_0_-_api_28 +PlayStore.enabled=false +abi.type=x86 +avd.ini.displayname=Phone Xh-DPI 4.7in Pie 9.0 - API 28 +avd.ini.encoding=UTF-8 +disk.dataPartition.size=2G +fastboot.forceColdBoot=no +fastboot.forceFastBoot=yes +hw.accelerometer=yes +hw.arc=no +hw.audioInput=yes +hw.battery=yes +hw.camera.back=virtualscene +hw.cpu.arch=x86 +hw.cpu.ncore=4 +hw.dPad=no +hw.device.hash2=MD5:ed3db32523e6d6f8210c6377d2c66883 +hw.device.manufacturer=Generic +hw.device.name=4.7in WXGA +hw.gps=yes +hw.gpu.enabled=yes +hw.gpu.mode=auto +hw.keyboard=yes +hw.lcd.density=320 +hw.lcd.height=720 +hw.lcd.width=1280 +hw.mainKeys=yes +hw.ramSize=512 +hw.sdCard=no +hw.sensors.orientation=yes +hw.sensors.proximity=yes +hw.trackBall=no +image.sysdir.1=system-images\android-28\default\x86\ +sdcard.size=512M +showDeviceFrame=no +skin.dynamic=yes +skin.name=1280x720 +tag.display=Default +tag.id=default +vm.heapSize=256 diff --git a/dotnet-devices.Tests/TestData/Android/AvdConfigIni_Phone.txt b/dotnet-devices.Tests/TestData/Android/AvdConfigIni_Phone.txt new file mode 100644 index 0000000..bc4e8ab --- /dev/null +++ b/dotnet-devices.Tests/TestData/Android/AvdConfigIni_Phone.txt @@ -0,0 +1,41 @@ +AvdId=pixel_2_q_10_0_-_api_29 +PlayStore.enabled=true +abi.type=x86 +avd.ini.displayname=Pixel 2 Q 10.0 - API 29 +avd.ini.encoding=UTF-8 +disk.dataPartition.size=6442450944 +fastboot.forceColdBoot=no +fastboot.forceFastBoot=yes +hw.accelerometer=yes +hw.arc=no +hw.audioInput=yes +hw.battery=yes +hw.camera.back=virtualscene +hw.camera.front=emulated +hw.cpu.arch=x86 +hw.cpu.ncore=4 +hw.dPad=no +hw.device.hash2=MD5:55acbc835978f326788ed66a5cd4c9a7 +hw.device.manufacturer=Google +hw.device.name=pixel_2 +hw.gps=yes +hw.gpu.enabled=yes +hw.gpu.mode=auto +hw.keyboard=yes +hw.lcd.density=420 +hw.lcd.height=1920 +hw.lcd.width=1080 +hw.mainKeys=no +hw.ramSize=1536 +hw.sdCard=yes +hw.sensors.orientation=yes +hw.sensors.proximity=yes +hw.trackBall=no +image.sysdir.1=system-images\android-29\google_apis_playstore\x86\ +sdcard.size=512M +showDeviceFrame=yes +skin.name=pixel_2 +skin.path=C:\Android\android-sdk\skins\pixel_2 +tag.display=Google Play +tag.id=google_apis_playstore +vm.heapSize=256 diff --git a/dotnet-devices.Tests/TestData/Android/AvdConfigIni_TV.txt b/dotnet-devices.Tests/TestData/Android/AvdConfigIni_TV.txt new file mode 100644 index 0000000..cb2dad9 --- /dev/null +++ b/dotnet-devices.Tests/TestData/Android/AvdConfigIni_TV.txt @@ -0,0 +1,41 @@ +AvdId=android_tv_720p_q_10_0_-_api_29 +PlayStore.enabled=false +abi.type=x86 +avd.ini.displayname=Android TV (720p) Q 10.0 - API 29 +avd.ini.encoding=UTF-8 +disk.dataPartition.size=2G +fastboot.forceColdBoot=no +fastboot.forceFastBoot=yes +hw.accelerometer=no +hw.arc=no +hw.audioInput=yes +hw.battery=no +hw.cpu.arch=x86 +hw.cpu.ncore=4 +hw.dPad=yes +hw.device.hash2=MD5:62a27947d2faec95c32cddffb87aa6ec +hw.device.manufacturer=Google +hw.device.name=tv_720p +hw.gps=yes +hw.gpu.enabled=yes +hw.gpu.mode=auto +hw.initialOrientation=landscape +hw.keyboard=yes +hw.keyboard.lid=yes +hw.lcd.density=213 +hw.lcd.height=720 +hw.lcd.width=1280 +hw.mainKeys=yes +hw.ramSize=1536 +hw.sdCard=yes +hw.sensors.orientation=no +hw.sensors.proximity=no +hw.trackBall=no +image.sysdir.1=system-images\android-29\android-tv\x86\ +sdcard.size=512M +showDeviceFrame=yes +skin.name=tv_720p +skin.path=C:\Program Files (x86)\Microsoft Visual Studio\2019\Enterprise\Common7\IDE\Extensions\Xamarin\AndroidDeviceManager\SystemSkins\tv_720p +tag.display=Android TV +tag.id=android-tv +vm.heapSize=256 diff --git a/dotnet-devices.Tests/TestData/Android/AvdConfigIni_Tablet.txt b/dotnet-devices.Tests/TestData/Android/AvdConfigIni_Tablet.txt new file mode 100644 index 0000000..92fe247 --- /dev/null +++ b/dotnet-devices.Tests/TestData/Android/AvdConfigIni_Tablet.txt @@ -0,0 +1,42 @@ +AvdId=nexus_10_pie_9_0_-_api_28 +PlayStore.enabled=false +abi.type=x86 +avd.ini.displayname=Nexus 10 Pie 9.0 - API 28 +avd.ini.encoding=UTF-8 +disk.dataPartition.size=2G +fastboot.forceColdBoot=no +fastboot.forceFastBoot=yes +hw.accelerometer=yes +hw.arc=no +hw.audioInput=yes +hw.battery=yes +hw.camera.back=virtualscene +hw.camera.front=emulated +hw.cpu.arch=x86 +hw.cpu.ncore=4 +hw.dPad=no +hw.device.hash2=MD5:8323741337b2d5dd4418b47c06a242b0 +hw.device.manufacturer=Google +hw.device.name=Nexus 10 +hw.gps=yes +hw.gpu.enabled=yes +hw.gpu.mode=auto +hw.initialOrientation=landscape +hw.keyboard=yes +hw.lcd.density=320 +hw.lcd.height=1600 +hw.lcd.width=2560 +hw.mainKeys=no +hw.ramSize=1536 +hw.sdCard=no +hw.sensors.orientation=yes +hw.sensors.proximity=no +hw.trackBall=no +image.sysdir.1=system-images\android-28\default\x86\ +sdcard.size=512M +showDeviceFrame=yes +skin.name=nexus_10 +skin.path=C:\Program Files (x86)\Microsoft Visual Studio\2019\Enterprise\Common7\IDE\Extensions\Xamarin\AndroidDeviceManager\SystemSkins\nexus_10 +tag.display=Default +tag.id=default +vm.heapSize=256 diff --git a/dotnet-devices.Tests/TestData/Android/AvdConfigIni_Wear.txt b/dotnet-devices.Tests/TestData/Android/AvdConfigIni_Wear.txt new file mode 100644 index 0000000..72e705d --- /dev/null +++ b/dotnet-devices.Tests/TestData/Android/AvdConfigIni_Wear.txt @@ -0,0 +1,41 @@ +AvdId=android_wear_round_pie_9_0_-_api_28 +PlayStore.enabled=false +abi.type=x86 +avd.ini.displayname=Android Wear Round Pie 9.0 - API 28 +avd.ini.encoding=UTF-8 +disk.dataPartition.size=2G +fastboot.forceColdBoot=no +fastboot.forceFastBoot=yes +hw.accelerometer=yes +hw.arc=no +hw.audioInput=yes +hw.battery=yes +hw.cpu.arch=x86 +hw.cpu.ncore=4 +hw.dPad=no +hw.device.hash2=MD5:3c452b52cce72363917e3cdd3c1c5954 +hw.device.manufacturer=Google +hw.device.name=wear_round +hw.gps=yes +hw.gpu.enabled=yes +hw.gpu.mode=auto +hw.initialOrientation=landscape +hw.keyboard=yes +hw.keyboard.lid=yes +hw.lcd.density=240 +hw.lcd.height=320 +hw.lcd.width=320 +hw.mainKeys=yes +hw.ramSize=512 +hw.sdCard=no +hw.sensors.orientation=yes +hw.sensors.proximity=yes +hw.trackBall=no +image.sysdir.1=system-images\android-28\android-wear\x86\ +sdcard.size=512M +showDeviceFrame=yes +skin.name=wear_round +skin.path=C:\Program Files (x86)\Microsoft Visual Studio\2019\Enterprise\Common7\IDE\Extensions\Xamarin\AndroidDeviceManager\SystemSkins\wear_round +tag.display=Android Wear +tag.id=android-wear +vm.heapSize=256 diff --git a/dotnet-devices.Tests/TestData/Android/CompiledXmlDump.txt b/dotnet-devices.Tests/TestData/Android/CompiledXmlDump_aapt.txt similarity index 100% rename from dotnet-devices.Tests/TestData/Android/CompiledXmlDump.txt rename to dotnet-devices.Tests/TestData/Android/CompiledXmlDump_aapt.txt diff --git a/dotnet-devices.Tests/TestData/Android/CompiledXmlDump_aapt2.txt b/dotnet-devices.Tests/TestData/Android/CompiledXmlDump_aapt2.txt new file mode 100644 index 0000000..a01f9e9 --- /dev/null +++ b/dotnet-devices.Tests/TestData/Android/CompiledXmlDump_aapt2.txt @@ -0,0 +1,51 @@ +N: android=http://schemas.android.com/apk/res/android (line=2) + E: manifest (line=2) + A: http://schemas.android.com/apk/res/android:versionCode(0x0101021b)=1 + A: http://schemas.android.com/apk/res/android:versionName(0x0101021c)="1.0.1.0" (Raw: "1.0.1.0") + A: http://schemas.android.com/apk/res/android:installLocation(0x010102b7)=0 + A: http://schemas.android.com/apk/res/android:compileSdkVersion(0x01010572)=29 + A: http://schemas.android.com/apk/res/android:compileSdkVersionCodename(0x01010573)="10" (Raw: "10") + A: package="net.dot.devicetests" (Raw: "net.dot.devicetests") + A: platformBuildVersionCode=29 + A: platformBuildVersionName=10 + E: uses-sdk (line=3) + A: http://schemas.android.com/apk/res/android:minSdkVersion(0x0101020c)=19 + A: http://schemas.android.com/apk/res/android:targetSdkVersion(0x01010270)=29 + E: uses-permission (line=4) + A: http://schemas.android.com/apk/res/android:name(0x01010003)="android.permission.INTERNET" (Raw: "android.permission.INTERNET") + E: uses-permission (line=5) + A: http://schemas.android.com/apk/res/android:name(0x01010003)="android.permission.READ_EXTERNAL_STORAGE" (Raw: "android.permission.READ_EXTERNAL_STORAGE") + E: application (line=6) + A: http://schemas.android.com/apk/res/android:theme(0x01010000)=@0x7f0d00c7 + A: http://schemas.android.com/apk/res/android:label(0x01010001)=@0x7f0c001b + A: http://schemas.android.com/apk/res/android:icon(0x01010002)=@0x7f07006f + A: http://schemas.android.com/apk/res/android:name(0x01010003)="android.app.Application" (Raw: "android.app.Application") + A: http://schemas.android.com/apk/res/android:debuggable(0x0101000f)=true + A: http://schemas.android.com/apk/res/android:allowBackup(0x01010280)=true + A: http://schemas.android.com/apk/res/android:appComponentFactory(0x0101057a)="androidx.core.app.CoreComponentFactory" (Raw: "androidx.core.app.CoreComponentFactory") + E: activity (line=7) + A: http://schemas.android.com/apk/res/android:name(0x01010003)="crc645c25d208dccba52c.MainActivity" (Raw: "crc645c25d208dccba52c.MainActivity") + A: http://schemas.android.com/apk/res/android:configChanges(0x0101001f)=0x00000480 + E: intent-filter (line=8) + E: action (line=9) + A: http://schemas.android.com/apk/res/android:name(0x01010003)="android.intent.action.MAIN" (Raw: "android.intent.action.MAIN") + E: category (line=10) + A: http://schemas.android.com/apk/res/android:name(0x01010003)="android.intent.category.LAUNCHER" (Raw: "android.intent.category.LAUNCHER") + E: service (line=13) + A: http://schemas.android.com/apk/res/android:name(0x01010003)="crc64396a3fe5f8138e3f.KeepAliveService" (Raw: "crc64396a3fe5f8138e3f.KeepAliveService") + E: receiver (line=14) + A: http://schemas.android.com/apk/res/android:name(0x01010003)="crc643f46942d9dd1fff9.PowerSaveModeBroadcastReceiver" (Raw: "crc643f46942d9dd1fff9.PowerSaveModeBroadcastReceiver") + A: http://schemas.android.com/apk/res/android:enabled(0x0101000e)=true + A: http://schemas.android.com/apk/res/android:exported(0x01010010)=false + E: provider (line=15) + A: http://schemas.android.com/apk/res/android:name(0x01010003)="mono.MonoRuntimeProvider" (Raw: "mono.MonoRuntimeProvider") + A: http://schemas.android.com/apk/res/android:exported(0x01010010)=false + A: http://schemas.android.com/apk/res/android:authorities(0x01010018)="net.dot.devicetests.mono.MonoRuntimeProvider.__mono_init__" (Raw: "net.dot.devicetests.mono.MonoRuntimeProvider.__mono_init__") + A: http://schemas.android.com/apk/res/android:initOrder(0x0101001a)=1999999999 + E: receiver (line=17) + A: http://schemas.android.com/apk/res/android:name(0x01010003)="mono.android.Seppuku" (Raw: "mono.android.Seppuku") + E: intent-filter (line=18) + E: action (line=19) + A: http://schemas.android.com/apk/res/android:name(0x01010003)="mono.android.intent.action.SEPPUKU" (Raw: "mono.android.intent.action.SEPPUKU") + E: category (line=20) + A: http://schemas.android.com/apk/res/android:name(0x01010003)="mono.android.intent.category.SEPPUKU.net.dot.devicetests" (Raw: "mono.android.intent.category.SEPPUKU.net.dot.devicetests") \ No newline at end of file diff --git a/dotnet-devices/Android/AVDManager.cs b/dotnet-devices/Android/AVDManager.cs index d8a4683..e8de93e 100644 --- a/dotnet-devices/Android/AVDManager.cs +++ b/dotnet-devices/Android/AVDManager.cs @@ -58,6 +58,34 @@ namespace DotNetDevices.Android return targets; } + public async Task> GetVirtualDeviceNamesAsync(CancellationToken cancellationToken = default) + { + logger?.LogInformation("Retrieving all the virtual devices..."); + + var args = $"list avd"; + + var result = await processRunner.RunAsync(avdmanager, args, null, cancellationToken).ConfigureAwait(false); + + var avds = new List(); + + foreach (var output in GetListResults(result)) + { + var pathMatch = virtualDevicePathRegex.Match(output); + if (pathMatch.Success) + { + var path = pathMatch.Groups[1].Value; + if (Directory.Exists(path)) + { + var avd = Path.GetFileNameWithoutExtension(path); + + avds.Add(avd); + } + } + } + + return avds; + } + public async Task> GetVirtualDevicesAsync(CancellationToken cancellationToken = default) { logger?.LogInformation("Retrieving all the virtual devices..."); @@ -94,7 +122,27 @@ namespace DotNetDevices.Android var args = $"delete avd --name \"{name}\""; - await processRunner.RunAsync(avdmanager, args, null, cancellationToken).ConfigureAwait(false); + try + { + await processRunner.RunAsync(avdmanager, args, null, cancellationToken).ConfigureAwait(false); + } + catch (ProcessResultException ex) when (WasExisting(ex.ProcessResult)) + { + // no-op + } + + bool WasExisting(ProcessResult result) + { + var expected = $"Error: There is no Android Virtual Device named '{name}'."; + + foreach (var output in result.GetErrorOutput()) + { + if (output.Contains(expected, StringComparison.OrdinalIgnoreCase)) + return true; + } + + return false; + } } public async Task CreateVirtualDeviceAsync(string name, string package, CreateVirtualDeviceOptions? options = null, CancellationToken cancellationToken = default) @@ -105,7 +153,27 @@ namespace DotNetDevices.Android if (options?.Overwrite == true) args += " --force"; - await processRunner.RunWithInputAsync("no", avdmanager, args, null, cancellationToken).ConfigureAwait(false); + try + { + await processRunner.RunWithInputAsync("no", avdmanager, args, null, cancellationToken).ConfigureAwait(false); + } + catch (ProcessResultException ex) when (WasExisting(ex.ProcessResult)) + { + // no-op + } + + bool WasExisting(ProcessResult result) + { + var expected = $"Error: Android Virtual Device '{name}' already exists."; + + foreach (var output in result.GetErrorOutput()) + { + if (output.Contains(expected, StringComparison.OrdinalIgnoreCase)) + return true; + } + + return false; + } } public async Task RenameVirtualDeviceAsync(string name, string newName, CancellationToken cancellationToken = default) diff --git a/dotnet-devices/Android/EmulatorManager.cs b/dotnet-devices/Android/EmulatorManager.cs index 2ca0043..a95f422 100644 --- a/dotnet-devices/Android/EmulatorManager.cs +++ b/dotnet-devices/Android/EmulatorManager.cs @@ -90,7 +90,7 @@ namespace DotNetDevices.Android } } - public async Task> GetVirtualDevicesAsync(CancellationToken cancellationToken = default) + public async Task> GetVirtualDevicesAsync(CancellationToken cancellationToken = default) { logger?.LogInformation("Retrieving all the virtual devices..."); @@ -98,10 +98,10 @@ namespace DotNetDevices.Android var result = await processRunner.RunAsync(emulator, args, null, cancellationToken).ConfigureAwait(false); - var avd = new List(result.OutputCount); + var avd = new List(result.OutputCount); foreach (var output in result.GetOutput()) { - avd.Add(new VirtualDevice(output, output)); + avd.Add(output.Trim()); } return avd; } diff --git a/dotnet-devices/Android/VirtualDevice.cs b/dotnet-devices/Android/VirtualDevice.cs index 91efb86..e6be798 100644 --- a/dotnet-devices/Android/VirtualDevice.cs +++ b/dotnet-devices/Android/VirtualDevice.cs @@ -4,21 +4,76 @@ namespace DotNetDevices.Android { public class VirtualDevice { - private readonly string? configPath; - - public VirtualDevice(string id, string name, string? configPath = null) + public VirtualDevice(string id, string name, string package, VirtualDeviceType type, int apiLevel, string? configPath) { Id = id ?? throw new ArgumentNullException(nameof(id)); Name = name ?? throw new ArgumentNullException(nameof(name)); - - this.configPath = configPath; + Package = package ?? throw new ArgumentNullException(nameof(package)); + Type = type; + ApiLevel = apiLevel; + ConfigPath = configPath; } public string Id { get; } public string Name { get; } + public string Package { get; } + + public VirtualDeviceType Type { get; } + + public int ApiLevel { get; } + + public string? ConfigPath { get; } + + public Version Version => + ApiLevel switch + { + 1 => new Version(1, 0), + 2 => new Version(1, 1), + 3 => new Version(1, 5), + 4 => new Version(1, 6), + 5 => new Version(2, 0), + 6 => new Version(2, 0, 1), + 7 => new Version(2, 1), + 8 => new Version(2, 2), + 9 => new Version(2, 3), + 10 => new Version(2, 3, 3), + 11 => new Version(3, 0), + 12 => new Version(3, 1), + 13 => new Version(3, 2), + 14 => new Version(4, 0), + 15 => new Version(4, 0, 3), + 16 => new Version(4, 1), + 17 => new Version(4, 2), + 18 => new Version(4, 3), + 19 => new Version(4, 4), + 20 => new Version(4, 4), // 4.4W (wear) + 21 => new Version(5, 0), + 22 => new Version(5, 1), + 23 => new Version(6, 0), + 24 => new Version(7, 0), + 25 => new Version(7, 1), + 26 => new Version(8, 0), + 27 => new Version(8, 1), + 28 => new Version(9, 0), + 29 => new Version(10, 0), + 30 => new Version(11, 0), + _ => new Version(), + }; + + public VirtualDeviceRuntime Runtime => + Type switch + { + VirtualDeviceType.Unknown => VirtualDeviceRuntime.Android, + VirtualDeviceType.Phone => VirtualDeviceRuntime.Android, + VirtualDeviceType.Tablet => VirtualDeviceRuntime.Android, + VirtualDeviceType.Wearable => VirtualDeviceRuntime.AndroidWear, + VirtualDeviceType.TV => VirtualDeviceRuntime.AndroidTV, + _ => VirtualDeviceRuntime.Android, + }; + public override string ToString() => - $"{Name}"; + $"{Name} (API {ApiLevel})"; } } diff --git a/dotnet-devices/Android/VirtualDeviceConfig.cs b/dotnet-devices/Android/VirtualDeviceConfig.cs index 5fd35af..ae6dc75 100644 --- a/dotnet-devices/Android/VirtualDeviceConfig.cs +++ b/dotnet-devices/Android/VirtualDeviceConfig.cs @@ -2,6 +2,7 @@ using System; using System.Collections.Generic; using System.IO; +using System.Text.RegularExpressions; using System.Threading; using System.Threading.Tasks; @@ -9,6 +10,8 @@ namespace DotNetDevices.Android { public class VirtualDeviceConfig { + private static readonly Regex androidApiRegex = new Regex(@"android-(\d+)"); + private readonly string configPath; private readonly ILogger? logger; @@ -47,12 +50,80 @@ namespace DotNetDevices.Android var props = await GetPropertiesAsync(cancellationToken).ConfigureAwait(false); if (!props.TryGetValue("avdid", out var id)) + { + var avdDir = Path.GetDirectoryName(configPath); + id = Path.GetFileNameWithoutExtension(avdDir); + } + + if (string.IsNullOrEmpty(id)) throw new Exception($"Invalid config.ini. Unable to find the virtual device ID."); if (!props.TryGetValue("avd.ini.displayname", out var name)) name = id; - return new VirtualDevice(id, name, configPath); + if (!props.TryGetValue("image.sysdir.1", out var package)) + package = ""; + var packageParts = package.Split(new[] { '\\', '/', ';' }, StringSplitOptions.RemoveEmptyEntries); + package = string.Join(";", packageParts); + + var apiLevel = 0; + if (packageParts.Length == 4) + { + var apiMatch = androidApiRegex.Match(packageParts[1]); + if (apiMatch.Success) + apiLevel = int.Parse(apiMatch.Groups[1].Value); + } + + if (!TryGetType(props, out var type)) + type = VirtualDeviceType.Unknown; + + return new VirtualDevice(id, name, package, type, apiLevel, configPath); + } + + private static bool TryGetType(IReadOnlyDictionary props, out VirtualDeviceType value) + { + if (props.TryGetValue("tag.id", out var type)) + { + switch (type.Trim().ToLowerInvariant()) + { + case "android-tv": + value = VirtualDeviceType.TV; + return true; + case "android-wear": + value = VirtualDeviceType.Wearable; + return true; + case "default": + case "google_apis": + case "google_apis_playstore": + value = + TryGetDimensions(props, out var width, out var height, out var density) + && Math.Min(width, height) / (density / 160) >= 600 + ? VirtualDeviceType.Tablet + : VirtualDeviceType.Phone; + return true; + } + } + + value = VirtualDeviceType.Unknown; + return false; + } + + private static bool TryGetDimensions(IReadOnlyDictionary props, out int width, out int height, out double density) + { + width = 0; + height = 0; + density = 160; + + if (!props.TryGetValue("hw.lcd.width", out var widthString) || !int.TryParse(widthString, out width)) + return false; + + if (!props.TryGetValue("hw.lcd.height", out var heightString) || !int.TryParse(heightString, out height)) + return false; + + if (!props.TryGetValue("hw.lcd.density", out var densityString) || !double.TryParse(densityString, out density)) + density = 160; + + return true; } private static Dictionary ParseConfig(string contents) diff --git a/dotnet-devices/Android/VirtualDeviceRuntime.cs b/dotnet-devices/Android/VirtualDeviceRuntime.cs new file mode 100644 index 0000000..67b477a --- /dev/null +++ b/dotnet-devices/Android/VirtualDeviceRuntime.cs @@ -0,0 +1,9 @@ +namespace DotNetDevices.Android +{ + public enum VirtualDeviceRuntime + { + Android, + AndroidWear, + AndroidTV + } +} diff --git a/dotnet-devices/Android/VirtualDeviceType.cs b/dotnet-devices/Android/VirtualDeviceType.cs new file mode 100644 index 0000000..b5e9a65 --- /dev/null +++ b/dotnet-devices/Android/VirtualDeviceType.cs @@ -0,0 +1,11 @@ +namespace DotNetDevices.Android +{ + public enum VirtualDeviceType + { + Unknown, + Phone, + Tablet, + Wearable, + TV + } +} diff --git a/dotnet-devices/Commands/AndroidCommand.cs b/dotnet-devices/Commands/AndroidCommand.cs index 580c6c4..62832e9 100644 --- a/dotnet-devices/Commands/AndroidCommand.cs +++ b/dotnet-devices/Commands/AndroidCommand.cs @@ -32,6 +32,12 @@ namespace DotNetDevices.Commands new Argument("NAME", "The name of the new virtual device."), new Argument("PACKAGE", "The package to use for the new virtual device."), }.WithHandler(CommandHandler.Create(typeof(AndroidCommand).GetMethod(nameof(HandleCreateAsync))!)), + new Command("delete", "Delete an existing virtual device.") + { + new Option(new[] { "--sdk" }, "The path to the Android SDK directory."), + CommandLine.CreateVerbosity(), + new Argument("NAME", "The name of the new virtual device."), + }.WithHandler(CommandHandler.Create(typeof(AndroidCommand).GetMethod(nameof(HandleDeleteAsync))!)), new Command("boot", "Boot a particular virtual device.") { new Option(new[] { "--sdk" }, "The path to the Android SDK directory."), @@ -60,31 +66,6 @@ namespace DotNetDevices.Commands var filtered = (IEnumerable)devices; - - //try - //{ - // await avdmanager.DeleteVirtualDeviceAsync("TESTING"); - //} - //catch { } - - //try - //{ - // await avdmanager.DeleteVirtualDeviceAsync("TESTED"); - //} - //catch { } - - //await avdmanager.CreateVirtualDeviceAsync("TESTING", "system-images;android-28;google_apis_playstore;x86_64"); - - //await avdmanager.CreateVirtualDeviceAsync("TESTING", "system-images;android-28;google_apis_playstore;x86_64", new VirtualDeviceCreateOptions { Overwrite = true }); - - //await avdmanager.RenameVirtualDeviceAsync("TESTING", "TESTED"); - //await avdmanager.MoveVirtualDeviceAsync("TESTED", "/Users/matthew/.android/avd/tested.avd"); - - //await avdmanager.DeleteVirtualDeviceAsync("TESTING"); - - - - //term = term?.ToLowerInvariant()?.Trim(); //var filtered = (IEnumerable)simulators; @@ -128,6 +109,10 @@ namespace DotNetDevices.Commands var table = new TableView(); table.AddColumn(s => s.Id, "Id"); table.AddColumn(s => s.Name, "Name"); + table.AddColumn(s => s.Type, "Type"); + table.AddColumn(s => s.Version, "Version"); + table.AddColumn(s => s.ApiLevel, "API Level"); + //table.AddColumn(s => s.State, "State"); table.Items = all; console.Append(new StackLayoutView { table }); @@ -146,14 +131,45 @@ namespace DotNetDevices.Commands var avdmanager = new AVDManager(sdk, logger); + if (!replace) + { + var devices = await avdmanager.GetVirtualDeviceNamesAsync(cancellationToken); + if (devices.Any(d => d.Equals(name, StringComparison.OrdinalIgnoreCase))) + { + logger.LogInformation($"Virtual device already exists."); + return; + } + } + var options = new CreateVirtualDeviceOptions { - Overwrite = replace + Overwrite = replace, }; await avdmanager.CreateVirtualDeviceAsync(name, package, options, cancellationToken); } + public static async Task HandleDeleteAsync( + string name, + string? sdk = null, + string? verbosity = null, + IConsole console = null!, + CancellationToken cancellationToken = default) + { + var logger = console.CreateLogger(verbosity); + + var avdmanager = new AVDManager(sdk, logger); + + var devices = await avdmanager.GetVirtualDeviceNamesAsync(cancellationToken); + if (devices.All(d => !d.Equals(name, StringComparison.OrdinalIgnoreCase))) + { + logger.LogInformation($"Virtual device does not exist."); + return; + } + + await avdmanager.DeleteVirtualDeviceAsync(name, cancellationToken); + } + public static async Task HandleBootAsync( string name, string? sdk = null, @@ -166,7 +182,7 @@ namespace DotNetDevices.Commands var emulator = new EmulatorManager(sdk, logger); var avds = await emulator.GetVirtualDevicesAsync(cancellationToken); - if (avds.All(a => !a.Id.Equals(name, StringComparison.OrdinalIgnoreCase))) + if (avds.All(a => !a.Equals(name, StringComparison.OrdinalIgnoreCase))) { logger.LogError($"No virtual device with name {name} was found."); return 1; diff --git a/dotnet-devices/Commands/AndroidTestCommand.cs b/dotnet-devices/Commands/AndroidTestCommand.cs index 0a7f6a5..26e5c60 100644 --- a/dotnet-devices/Commands/AndroidTestCommand.cs +++ b/dotnet-devices/Commands/AndroidTestCommand.cs @@ -1,6 +1,8 @@ using DotNetDevices.Android; using Microsoft.Extensions.Logging; using System; +using System.Collections.Generic; +using System.Linq; using System.Threading; using System.Threading.Tasks; @@ -45,193 +47,198 @@ namespace DotNetDevices.Commands logger.LogInformation($"Running tests on '{packageName}'..."); - //// validate requested OS - //var simulatorType = ParseSimulatorType(deviceType); - //var runtime = ParseSimulatorRuntime(runtimeString); - //var runtimeVersion = await ParseVersionAsync(versionString, runtime, cancellationToken); + // validate requested OS + var avdRuntime = ParseDeviceRuntime(runtimeString); + var avdTypes = ParseDeviceTypes(deviceType, avdRuntime); + var avdApiLevel = ParseApiLevel(versionString); + if (avdApiLevel == 0) + latest = true; - //logger.LogInformation($"Looking for an available {simulatorType} ({runtimeVersion}) simulator..."); - //var available = await GetAvailableSimulatorsAsync(simulatorType, runtime, runtimeVersion, latest, cancellationToken); + logger.LogInformation($"Looking for an available {string.Join("|", avdTypes)}{(avdApiLevel == 0 ? "" : $" (API {avdApiLevel})")} virtual device..."); + var available = await GetAvailableDevicesAsync(deviceName, avdTypes, avdApiLevel, latest, cancellationToken); - //// first look for a booted device - //var simulator = available.FirstOrDefault(s => s.State == SimulatorState.Booted) ?? available.FirstOrDefault(); - //logger.LogInformation($"Using simulator {simulator.Name} ({simulator.Runtime} {simulator.Version}): {simulator.Udid}"); + // get the first device + var avd = available.FirstOrDefault(); + logger.LogInformation($"Using virtual device {avd.Name} ({avd.Runtime} {avd.Version}): {avd.Id}"); - //try - //{ - // if (reset) - // await simctl.EraseSimulatorAsync(simulator.Udid, true, cancellationToken); + try + { + // if (reset) + // await simctl.EraseSimulatorAsync(simulator.Udid, true, cancellationToken); - // await simctl.InstallAppAsync(simulator.Udid, app, true, cancellationToken); + // await simctl.InstallAppAsync(simulator.Udid, app, true, cancellationToken); - // try - // { - // var parser = new TestResultsParser(); + try + { + // var parser = new TestResultsParser(); - // var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + // var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); - // var launched = await simctl.LaunchAppAsync(simulator.Udid, bundleId, new LaunchAppOptions - // { - // CaptureOutput = true, - // BootSimulator = true, - // HandleOutput = output => - // { - // parser.ParseTestOutput( - // output, - // line => logger?.LogWarning(line), - // async () => - // { - // try - // { - // // wait a few seconds before terminating - // await Task.Delay(1000, cts.Token); + // var launched = await simctl.LaunchAppAsync(simulator.Udid, bundleId, new LaunchAppOptions + // { + // CaptureOutput = true, + // BootSimulator = true, + // HandleOutput = output => + // { + // parser.ParseTestOutput( + // output, + // line => logger?.LogWarning(line), + // async () => + // { + // try + // { + // // wait a few seconds before terminating + // await Task.Delay(1000, cts.Token); - // await simctl.TerminateAppAsync(simulator.Udid, bundleId, cts.Token); - // } - // catch (OperationCanceledException) - // { - // // we expected this - // } - // }); - // }, - // }, cancellationToken); + // await simctl.TerminateAppAsync(simulator.Udid, bundleId, cts.Token); + // } + // catch (OperationCanceledException) + // { + // // we expected this + // } + // }); + // }, + // }, cancellationToken); - // cts.Cancel(); + // cts.Cancel(); - // if (deviceResults != null) - // { - // var dest = outputResults ?? Path.GetFileName(deviceResults); + // if (deviceResults != null) + // { + // var dest = outputResults ?? Path.GetFileName(deviceResults); - // logger.LogInformation($"Copying test results from simulator to {dest}..."); + // logger.LogInformation($"Copying test results from simulator to {dest}..."); - // var dataPath = await simctl.GetDataDirectoryAsync(simulator.Udid, bundleId, cancellationToken); - // var results = Path.Combine(dataPath, "Documents", deviceResults); - // if (File.Exists(results)) - // File.Copy(results, dest, true); - // else - // logger.LogInformation($"No test results found."); - // } - // else - // { - // logger.LogInformation($"Unable to determine the test results file."); - // } - // } - // finally - // { - // await simctl.UninstallAppAsync(simulator.Udid, bundleId, false, cancellationToken); - // } - //} - //finally - //{ - // if (shutdown) - // await simctl.ShutdownSimulatorAsync(simulator.Udid, cancellationToken); - //} + // var dataPath = await simctl.GetDataDirectoryAsync(simulator.Udid, bundleId, cancellationToken); + // var results = Path.Combine(dataPath, "Documents", deviceResults); + // if (File.Exists(results)) + // File.Copy(results, dest, true); + // else + // logger.LogInformation($"No test results found."); + // } + // else + // { + // logger.LogInformation($"Unable to determine the test results file."); + // } + } + finally + { + // await simctl.UninstallAppAsync(simulator.Udid, bundleId, false, cancellationToken); + } + } + finally + { + // if (shutdown) + // await simctl.ShutdownSimulatorAsync(simulator.Udid, cancellationToken); + } } - //private async Task> GetAvailableSimulatorsAsync(SimulatorType type, SimulatorRuntime runtime, Version version, bool useLatest = true, CancellationToken cancellationToken = default) - //{ - // // load all simulators - // var simulators = await simctl.GetSimulatorsAsync(cancellationToken); + private async Task> GetAvailableDevicesAsync(string? deviceName, VirtualDeviceType[] types, int apiLevel, bool useLatest = true, CancellationToken cancellationToken = default) + { + // load all virtual devices + var avds = await avdmanager.GetVirtualDevicesAsync(cancellationToken); - // // find ones that can be used - // var available = simulators - // .Where(s => s.Availability == SimulatorAvailability.Available) - // .Where(s => s.Runtime == runtime) - // .Where(s => s.Type == type); - // logger.LogDebug($"Found some available simulators:"); - // foreach (var sim in available) - // { - // logger.LogDebug($" {sim.Name} ({sim.Runtime} {sim.Version}): {sim.Udid}"); - // } + // use the name directly + if (!string.IsNullOrEmpty(deviceName)) + return avds.Where(d => d.Id == deviceName || d.Name == deviceName).ToList(); - // // filter by version info - // string matchingPattern; - // if (useLatest) - // { - // var min = version; - // var max = new Version(min.Major + 1, 0); - // available = available.Where(s => s.Version >= min && s.Version < max); - // matchingPattern = $"[{min}, {max})"; - // } - // else - // { - // available = available.Where(s => s.Version == version); - // matchingPattern = $"[{version}]"; - // } + // find ones that can be used + var available = avds + .Where(s => types.Contains(s.Type)); + logger.LogDebug($"Found some available virtual devices:"); + foreach (var avd in available) + { + logger.LogDebug($" {avd.Name} ({avd.Runtime} API {avd.ApiLevel}): {avd.Id}"); + } - // var matching = available.ToList(); - // if (matching.Count > 0) - // { - // logger.LogDebug($"Found matching simulators {matchingPattern}:"); - // foreach (var sim in matching) - // { - // logger.LogDebug($" {sim.Name} ({sim.Runtime} {sim.Version}): {sim.Udid}"); - // } - // } - // else - // { - // throw new Exception($"Unable to find any simulators that match version {matchingPattern}."); - // } + // filter by version info + string matchingPattern; + if (useLatest) + { + var max = available.Where(d => d.ApiLevel >= apiLevel).Max(d => d.ApiLevel); + available = available.Where(d => d.ApiLevel == max); + matchingPattern = apiLevel > 0 ? $"[{apiLevel})" : $"[{max}]"; + } + else + { + available = available.Where(d => d.ApiLevel == apiLevel); + matchingPattern = $"[{apiLevel}]"; + } - // return matching; - //} + var matching = available.ToList(); + if (matching.Count == 0) + throw new Exception($"Unable to find any virtual devices that match version {matchingPattern}."); - //private async Task ParseVersionAsync(string? version, SimulatorRuntime os, CancellationToken cancellationToken = default) - //{ - // var osVersion = version?.ToLowerInvariant().Trim(); - // if (!Version.TryParse(osVersion, out var numberVersion)) - // { - // if (int.TryParse(osVersion, out var v)) - // numberVersion = new Version(v, 0); - // else if (string.IsNullOrEmpty(osVersion) || osVersion == "default") - // numberVersion = await simctl.GetDefaultVersionAsync(os, cancellationToken); - // else - // throw new Exception($"Unable to determine the version for {osVersion}."); - // } + logger.LogDebug($"Found matching virtual devices {matchingPattern}:"); + foreach (var avd in matching) + { + logger.LogDebug($" {avd.Name} ({avd.Runtime} API {avd.ApiLevel}): {avd.Id}"); + } - // return numberVersion; - //} + return matching; + } - //private static SimulatorRuntime ParseSimulatorRuntime(string? runtime) - //{ - // var osName = runtime?.ToLowerInvariant()?.Trim(); - // var os = osName switch - // { - // null => SimulatorRuntime.iOS, - // "" => SimulatorRuntime.iOS, - // "ios" => SimulatorRuntime.iOS, - // "watchos" => SimulatorRuntime.watchOS, - // "tvos" => SimulatorRuntime.tvOS, - // _ => throw new Exception($"Unable to determine the OS for {runtime}.") - // }; - // return os; - //} + private int ParseApiLevel(string? version) + { + var osVersion = version?.ToLowerInvariant().Trim(); - //private static SimulatorType ParseSimulatorType(string? deviceType) - //{ - // var deviceTypeName = deviceType?.ToLowerInvariant()?.Trim(); - // var device = deviceTypeName switch - // { - // // iPhone - // null => SimulatorType.iPhone, - // "" => SimulatorType.iPhone, - // "iphone" => SimulatorType.iPhone, - // "phone" => SimulatorType.iPhone, - // // iPad - // "ipad" => SimulatorType.iPad, - // "tablet" => SimulatorType.iPad, - // // iPod - // "ipod" => SimulatorType.iPod, - // // Apple TV - // "tv" => SimulatorType.AppleTV, - // "appletv" => SimulatorType.AppleTV, - // // Apple Watch - // "watch" => SimulatorType.AppleWatch, - // "applewatch" => SimulatorType.AppleWatch, - // // - // _ => throw new Exception($"Unable to determine the simulator type for {deviceType}.") - // }; - // return device; - //} + if (string.IsNullOrEmpty(osVersion)) + return 0; + + if (!int.TryParse(osVersion, out var numberVersion)) + throw new Exception($"Unable to determine the version for {osVersion}."); + + return numberVersion; + } + + private static VirtualDeviceRuntime ParseDeviceRuntime(string? runtime) + { + var osName = runtime?.ToLowerInvariant()?.Trim(); + var os = osName switch + { + null => VirtualDeviceRuntime.Android, + "" => VirtualDeviceRuntime.Android, + "android" => VirtualDeviceRuntime.Android, + "watch" => VirtualDeviceRuntime.AndroidWear, + "wear" => VirtualDeviceRuntime.AndroidWear, + "androidwear" => VirtualDeviceRuntime.AndroidWear, + "wearable" => VirtualDeviceRuntime.AndroidWear, + "tv" => VirtualDeviceRuntime.AndroidTV, + "androidtv" => VirtualDeviceRuntime.AndroidTV, + _ => throw new Exception($"Unable to determine the OS for {runtime}.") + }; + return os; + } + + private static VirtualDeviceType[] ParseDeviceTypes(string? deviceType, VirtualDeviceRuntime runtime) + { + var fallback = runtime switch + { + VirtualDeviceRuntime.Android => new[] { VirtualDeviceType.Phone, VirtualDeviceType.Tablet }, + VirtualDeviceRuntime.AndroidWear => new[] { VirtualDeviceType.Wearable }, + VirtualDeviceRuntime.AndroidTV => new[] { VirtualDeviceType.TV }, + _ => new[] { VirtualDeviceType.Phone | VirtualDeviceType.Tablet }, + }; + + var deviceTypeName = deviceType?.ToLowerInvariant()?.Trim(); + var device = deviceTypeName switch + { + // phone + null => fallback, + "" => fallback, + "phone" => new[] { VirtualDeviceType.Phone }, + // tablet + "tab" => new[] { VirtualDeviceType.Tablet }, + "tablet" => new[] { VirtualDeviceType.Tablet }, + // TV + "tv" => new[] { VirtualDeviceType.TV }, + // Wear + "watch" => new[] { VirtualDeviceType.Wearable }, + "wear" => new[] { VirtualDeviceType.Wearable }, + "wearable" => new[] { VirtualDeviceType.Wearable }, + // + _ => throw new Exception($"Unable to determine the virtual device type for {deviceType}.") + }; + return device; + } } } diff --git a/dotnet-devices/Processes/ProcessResult.cs b/dotnet-devices/Processes/ProcessResult.cs index c0d443c..6cf28ee 100644 --- a/dotnet-devices/Processes/ProcessResult.cs +++ b/dotnet-devices/Processes/ProcessResult.cs @@ -1,7 +1,6 @@ using System; using System.Collections.Generic; using System.Text; -using DotNetDevices.Android; namespace DotNetDevices.Processes { @@ -43,6 +42,13 @@ namespace DotNetDevices.Processes yield return item.Data; } + public IEnumerable GetErrorOutput() + { + foreach (var item in outputItems) + if (item.IsError) + yield return item.Data; + } + public override string ToString() => $"Completed with exit code {ExitCode} in {Elapsed}.";