diff --git a/MetaforceInstaller.Cli/MetaforceInstaller.Cli.csproj b/MetaforceInstaller.Cli/MetaforceInstaller.Cli.csproj
new file mode 100644
index 0000000..a267acc
--- /dev/null
+++ b/MetaforceInstaller.Cli/MetaforceInstaller.Cli.csproj
@@ -0,0 +1,24 @@
+
+
+
+ Exe
+ net8.0
+ enable
+ true
+ true
+ enable
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/MetaforceInstaller.Cli/Program.cs b/MetaforceInstaller.Cli/Program.cs
new file mode 100644
index 0000000..bf8bf51
--- /dev/null
+++ b/MetaforceInstaller.Cli/Program.cs
@@ -0,0 +1,133 @@
+using MetaforceInstaller.Cli.Utils;
+using MetaforceInstaller.Core.Services;
+
+namespace MetaforceInstaller.Cli;
+
+static class Program
+{
+ static async Task Main(string[] args)
+ {
+ try
+ {
+ var installationRequest = ArgumentParser.ParseArguments(args);
+
+ if (installationRequest is null ||
+ string.IsNullOrEmpty(installationRequest.ApkPath) ||
+ string.IsNullOrEmpty(installationRequest.ZipPath))
+ {
+ ShowUsage();
+ return;
+ }
+
+ var adbService = new AdbService();
+
+ var apkInfo = ApkScrapper.GetApkInfo(installationRequest.ApkPath);
+ var zipName = Path.GetFileName(installationRequest.ZipPath);
+ var outputPath =
+ @$"/storage/emulated/0/Android/data/{apkInfo.PackageName}/files/{zipName}";
+
+ // Подписка на события прогресса
+ adbService.ProgressChanged += OnProgressChanged;
+ adbService.StatusChanged += OnStatusChanged;
+
+ // Получение информации об устройстве
+ var deviceInfo = adbService.GetDeviceInfo();
+ Console.WriteLine($"Найдено устройство: {deviceInfo.SerialNumber}");
+ Console.WriteLine($"Состояние: {deviceInfo.State}");
+ Console.WriteLine($"Модель: {deviceInfo.Model} - {deviceInfo.Name}");
+ Console.WriteLine();
+
+ // Создание объекта для отслеживания прогресса
+ var progress = new Progress(OnProgressReport);
+
+ // Установка APK
+ await adbService.InstallApkAsync(installationRequest.ApkPath, progress);
+ Console.WriteLine();
+
+ // Копирование файла
+ await adbService.CopyFileAsync(installationRequest.ZipPath, outputPath, progress);
+ Console.WriteLine();
+
+ Console.WriteLine("Операция завершена успешно!");
+ }
+ catch (Exception ex)
+ {
+ Console.WriteLine($"Ошибка: {ex.Message}");
+ }
+ }
+
+ private static void OnProgressChanged(object? sender, MetaforceInstaller.Core.Models.ProgressInfo e)
+ {
+ DrawProgressBar(e.PercentageComplete, e.BytesTransferred, e.TotalBytes);
+ }
+
+ private static void OnStatusChanged(object? sender, string e)
+ {
+ Console.WriteLine(e);
+ }
+
+ private static void OnProgressReport(MetaforceInstaller.Core.Models.ProgressInfo progressInfo)
+ {
+ if (progressInfo.TotalBytes > 0)
+ {
+ DrawProgressBar(progressInfo.PercentageComplete, progressInfo.BytesTransferred, progressInfo.TotalBytes);
+ }
+ else
+ {
+ // Для случаев без информации о байтах (например, установка APK)
+ DrawProgressBar(progressInfo.PercentageComplete, 0, 100);
+ }
+ }
+
+ static void ShowUsage()
+ {
+ Console.WriteLine("Использование:");
+ Console.WriteLine(
+ " MetaforceInstaller.exe --apk <путь_к_apk> --content <путь_к_zip> --output <путь_для контента>");
+ Console.WriteLine(" MetaforceInstaller.exe -a <путь_к_apk> -c <путь_к_zip> -o <путь_для_контента>");
+ Console.WriteLine();
+ Console.WriteLine("Параметры:");
+ Console.WriteLine(" --apk, -a Путь к APK файлу");
+ Console.WriteLine(" --content, -c Путь к ZIP файлу с контентом");
+ Console.WriteLine(" --output, -o Путь для копирования контента");
+ Console.WriteLine(" --help, -h Показать эту справку");
+ Console.WriteLine();
+ Console.WriteLine("Пример:");
+ Console.WriteLine(
+ " MetaforceInstaller.exe --apk \"C:\\app.apk\" --content \"C:\\data.zip\" --output \"/sdcard/data.zip\"");
+ Console.WriteLine(" MetaforceInstaller.exe -a app.apk -c data.zip -o /sdcard/data.zip");
+ }
+
+ private static void DrawProgressBar(int progress, long receivedBytes, long totalBytes)
+ {
+ Console.SetCursorPosition(0, Console.CursorTop);
+
+ var barLength = 40;
+ var filledLength = (int)(barLength * progress / 100.0);
+
+ var bar = "[" + new string('█', filledLength) + new string('░', barLength - filledLength) + "]";
+
+ string bytesText = "";
+ if (totalBytes > 0)
+ {
+ bytesText = $" {FormatBytes(receivedBytes)} / {FormatBytes(totalBytes)}";
+ }
+
+ Console.Write($"\r{bar} {progress}%{bytesText}");
+ }
+
+ private static string FormatBytes(long bytes)
+ {
+ string[] suffixes = ["B", "KB", "MB", "GB", "TB"];
+ var counter = 0;
+ double number = bytes;
+
+ while (Math.Round(number / 1024) >= 1)
+ {
+ number /= 1024;
+ counter++;
+ }
+
+ return $"{number:N1} {suffixes[counter]}";
+ }
+}
\ No newline at end of file
diff --git a/MetaforceInstaller.Cli/Utils/ArgumentParser.cs b/MetaforceInstaller.Cli/Utils/ArgumentParser.cs
new file mode 100644
index 0000000..ffdb561
--- /dev/null
+++ b/MetaforceInstaller.Cli/Utils/ArgumentParser.cs
@@ -0,0 +1,53 @@
+using MetaforceInstaller.Core.Models;
+
+namespace MetaforceInstaller.Cli.Utils;
+
+public static class ArgumentParser
+{
+ public static InstallationRequest? ParseArguments(string[] args)
+ {
+ var result = new InstallationRequest();
+
+ for (var i = 0; i < args.Length; i++)
+ {
+ switch (args[i].ToLower())
+ {
+ case "--apk":
+ case "-a":
+ if (i + 1 < args.Length)
+ {
+ result.ApkPath = args[i + 1];
+ i++;
+ }
+
+ break;
+
+ case "--content":
+ case "-c":
+ if (i + 1 < args.Length)
+ {
+ result.ZipPath = args[i + 1];
+ i++;
+ }
+
+ break;
+
+ case "--output":
+ case "-o":
+ if (i + 1 < args.Length)
+ {
+ result.OutputPath = args[i + 1];
+ i++;
+ }
+
+ break;
+
+ case "--help":
+ case "-h":
+ return null;
+ }
+ }
+
+ return result;
+ }
+}
\ No newline at end of file
diff --git a/MetaforceInstaller.Cloud/ICloudService.cs b/MetaforceInstaller.Cloud/ICloudService.cs
deleted file mode 100644
index a179bba..0000000
--- a/MetaforceInstaller.Cloud/ICloudService.cs
+++ /dev/null
@@ -1,8 +0,0 @@
-using MetaforceInstaller.Cloud.Models;
-
-namespace MetaforceInstaller.Cloud;
-
-public interface ICloudService
-{
- Task> GetObjects(string webLink);
-}
\ No newline at end of file
diff --git a/MetaforceInstaller.Cloud/Implementations/MailRu/DTO/CloudObjectResponse.cs b/MetaforceInstaller.Cloud/Implementations/MailRu/DTO/CloudObjectResponse.cs
deleted file mode 100644
index 7f2bdb3..0000000
--- a/MetaforceInstaller.Cloud/Implementations/MailRu/DTO/CloudObjectResponse.cs
+++ /dev/null
@@ -1,181 +0,0 @@
-using System.Text.Json;
-using System.Text.Json.Serialization;
-
-namespace MetaforceInstaller.Cloud.Implementations.MailRu.DTO;
-
-public sealed class CloudObjectsResponse
-{
- [JsonPropertyName("count")]
- public CountInfo? Count { get; init; }
-
- [JsonPropertyName("name")]
- public string? Name { get; init; }
-
- [JsonPropertyName("weblink")]
- public string? WebLink { get; init; }
-
- [JsonPropertyName("size")]
- public long? Size { get; init; }
-
- [JsonPropertyName("rev")]
- public long? Rev { get; init; }
-
- [JsonPropertyName("kind")]
- public string? Kind { get; init; }
-
- [JsonPropertyName("type")]
- public string? Type { get; init; }
-
- [JsonPropertyName("public")]
- public PublicInfo? Public { get; init; }
-
- [JsonPropertyName("list")]
- public List? List { get; init; }
-
- [JsonPropertyName("owner")]
- public OwnerInfo? Owner { get; init; }
-}
-
-public sealed class CountInfo
-{
- [JsonPropertyName("folders")]
- public int? Folders { get; init; }
-
- [JsonPropertyName("files")]
- public int? Files { get; init; }
-}
-
-public sealed class PublicInfo
-{
- [JsonPropertyName("type")]
- public string? Type { get; init; }
-
- [JsonPropertyName("name")]
- public string? Name { get; init; }
-
- [JsonPropertyName("id")]
- public string? Id { get; init; }
-
- [JsonPropertyName("ctime")]
- public long? CTime { get; init; }
-
- [JsonPropertyName("views")]
- public long? Views { get; init; }
-
- [JsonPropertyName("downloads")]
- public long? Downloads { get; init; }
-}
-
-public sealed class OwnerInfo
-{
- [JsonPropertyName("email")]
- public string? Email { get; init; }
-
- [JsonPropertyName("user_flags")]
- public UserFlags? UserFlags { get; init; }
-}
-
-public sealed class UserFlags
-{
- [JsonPropertyName("PAID_ACCOUNT")]
- public bool? PaidAccount { get; init; }
-}
-
-///
-/// Базовый тип элемента из "list". Конкретный тип выбирается по полю "type".
-///
-[JsonConverter(typeof(CloudObjectJsonConverter))]
-public abstract class CloudObjectBase
-{
- [JsonPropertyName("name")]
- public string? Name { get; init; }
-
- [JsonPropertyName("weblink")]
- public string? WebLink { get; init; }
-
- [JsonPropertyName("size")]
- public long? Size { get; init; }
-
- [JsonPropertyName("kind")]
- public string? Kind { get; init; }
-
- [JsonPropertyName("type")]
- public string? Type { get; init; }
-}
-
-public sealed class CloudFolderObject : CloudObjectBase
-{
- [JsonPropertyName("count")]
- public CountInfo? Count { get; init; }
-
- [JsonPropertyName("rev")]
- public long? Rev { get; init; }
-}
-
-public sealed class CloudFileObject : CloudObjectBase
-{
- [JsonPropertyName("mtime")]
- public long? MTime { get; init; }
-
- [JsonPropertyName("hash")]
- public string? Hash { get; init; }
-}
-
-public sealed class ZipWebLinkRequest
-{
- [JsonPropertyName("x-email")]
- public string XEmail { get; init; }
-
- [JsonPropertyName("weblink_list")]
- public IReadOnlyList WeblinkList { get; init; }
-
- [JsonPropertyName("name")]
- public string Name { get; init; }
-}
-
-public sealed class ZipWebLinkResponse
-{
- [JsonPropertyName("key")]
- public string Key { get; init; }
-}
-
-///
-/// Конвертер, который смотрит на поле "type" и десериализует в CloudFolderObject или CloudFileObject.
-///
-public sealed class CloudObjectJsonConverter : JsonConverter
-{
- public override CloudObjectBase Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
- {
- using var doc = JsonDocument.ParseValue(ref reader);
-
- var root = doc.RootElement;
- if (!root.TryGetProperty("type", out var typeProp))
- throw new JsonException("Missing required property 'type' for cloud object.");
-
- var type = typeProp.GetString();
-
- return type switch
- {
- "folder" => root.Deserialize(options)
- ?? throw new JsonException("Failed to deserialize folder object."),
- "file" => root.Deserialize(options)
- ?? throw new JsonException("Failed to deserialize file object."),
- _ => throw new JsonException($"Unknown cloud object type '{type}'.")
- };
- }
-
- public override void Write(Utf8JsonWriter writer, CloudObjectBase value, JsonSerializerOptions options)
- {
- switch (value)
- {
- case CloudFolderObject folder:
- JsonSerializer.Serialize(writer, folder, options);
- break;
- case CloudFileObject file:
- JsonSerializer.Serialize(writer, file, options);
- break;
- default:
- throw new JsonException($"Unknown runtime type '{value.GetType().Name}'.");
- }
- }
-}
\ No newline at end of file
diff --git a/MetaforceInstaller.Cloud/Implementations/MailRu/MailRuCloudService.cs b/MetaforceInstaller.Cloud/Implementations/MailRu/MailRuCloudService.cs
deleted file mode 100644
index d7ae0ca..0000000
--- a/MetaforceInstaller.Cloud/Implementations/MailRu/MailRuCloudService.cs
+++ /dev/null
@@ -1,116 +0,0 @@
-using System.Text;
-using System.Text.Json;
-using System.Web;
-using MetaforceInstaller.Cloud.Implementations.MailRu.DTO;
-using MetaforceInstaller.Cloud.Models;
-
-namespace MetaforceInstaller.Cloud.Implementations.MailRu;
-
-public class MailRuCloudService : ICloudService
-{
- private const string BaseUrl = "https://cloud.mail.ru";
-
- private static readonly JsonSerializerOptions JsonOptions = new()
- {
- PropertyNameCaseInsensitive = true
- };
-
- private readonly HttpClient _httpClient;
-
- public MailRuCloudService(HttpClient httpClient)
- {
- _httpClient = httpClient;
- }
-
- public async Task> GetObjects(string webLink)
- {
- var dto = await GetFromPublicApi(
- path: "/api/v4/public/list",
- query: new Dictionary
- {
- ["weblink"] = webLink,
- ["sort"] = "name",
- ["order"] = "asc",
- ["offset"] = "0",
- ["limit"] = "500",
- ["version"] = "4"
- }).ConfigureAwait(false);
-
- if (dto?.List is null || dto.List.Count == 0)
- return [];
-
- var result = new List(dto.List.Count);
- foreach (var entry in dto.List)
- result.Add(Map(entry));
-
- return result;
- }
-
- public async Task GetDownloadLink(IEnumerable webLinks, string outputFileName = "archive")
- {
- var result = await PostToPublicApi(
- path: "/api/v3/zip/weblink",
- query: new Dictionary(),
- new ZipWebLinkRequest
- {
- XEmail = "anonym",
- WeblinkList = webLinks.ToList(),
- Name = outputFileName
- }
- );
-
- return result?.Key;
- }
-
- private async Task GetFromPublicApi(string path, IReadOnlyDictionary query)
- {
- var uri = BuildUri(path, query);
-
- using var response = await _httpClient.GetAsync(uri).ConfigureAwait(false);
- response.EnsureSuccessStatusCode();
-
- var json = await response.Content.ReadAsStringAsync().ConfigureAwait(false);
- return JsonSerializer.Deserialize(json, JsonOptions);
- }
-
- private async Task PostToPublicApi(string path, IReadOnlyDictionary query,
- object body)
- {
- var uri = BuildUri(path, query);
-
- var jsonBody = JsonSerializer.Serialize(body, JsonOptions);
- using var content = new StringContent(jsonBody, Encoding.UTF8, "application/json");
-
- using var response = await _httpClient.PostAsync(uri, content).ConfigureAwait(false);
- response.EnsureSuccessStatusCode();
-
- var json = await response.Content.ReadAsStringAsync().ConfigureAwait(false);
- return JsonSerializer.Deserialize(json, JsonOptions);
- }
-
- private static Uri BuildUri(string path, IReadOnlyDictionary query)
- {
- var builder = new UriBuilder(BaseUrl)
- {
- Port = -1,
- Path = path
- };
-
- var qs = HttpUtility.ParseQueryString(builder.Query);
- foreach (var (key, value) in query)
- qs[key] = value;
-
- builder.Query = qs.ToString();
- return builder.Uri;
- }
-
- private static CloudObject Map(CloudObjectBase entry)
- {
- return new CloudObject
- {
- Name = entry.Name,
- WebLink = entry.WebLink,
- Type = entry.Type == "folder" ? CloudObjectType.Folder : CloudObjectType.File
- };
- }
-}
\ No newline at end of file
diff --git a/MetaforceInstaller.Cloud/MetaforceInstaller.Cloud.csproj b/MetaforceInstaller.Cloud/MetaforceInstaller.Cloud.csproj
deleted file mode 100644
index 17b910f..0000000
--- a/MetaforceInstaller.Cloud/MetaforceInstaller.Cloud.csproj
+++ /dev/null
@@ -1,9 +0,0 @@
-
-
-
- net9.0
- enable
- enable
-
-
-
diff --git a/MetaforceInstaller.Cloud/Models/CloudObject.cs b/MetaforceInstaller.Cloud/Models/CloudObject.cs
deleted file mode 100644
index 643a9d5..0000000
--- a/MetaforceInstaller.Cloud/Models/CloudObject.cs
+++ /dev/null
@@ -1,14 +0,0 @@
-namespace MetaforceInstaller.Cloud.Models;
-
-public class CloudObject
-{
- public string Name { get; set; }
- public string WebLink { get; set; }
- public CloudObjectType Type { get; set; }
-}
-
-public enum CloudObjectType
-{
- Folder,
- File
-}
\ No newline at end of file
diff --git a/MetaforceInstaller.Core/Intefaces/IAdbService.cs b/MetaforceInstaller.Core/Intefaces/IAdbService.cs
new file mode 100644
index 0000000..444c237
--- /dev/null
+++ b/MetaforceInstaller.Core/Intefaces/IAdbService.cs
@@ -0,0 +1,18 @@
+using MetaforceInstaller.Core.Models;
+using MetaforceInstaller.Core.Models;
+
+namespace MetaforceInstaller.Core.Intefaces;
+
+public interface IAdbService
+{
+ event EventHandler? ProgressChanged;
+ event EventHandler? StatusChanged;
+
+ Task InstallApkAsync(string apkPath, IProgress? progress = null, CancellationToken cancellationToken = default);
+ Task CopyFileAsync(string localPath, string remotePath, IProgress? progress = null, CancellationToken cancellationToken = default);
+ DeviceInfo GetDeviceInfo();
+
+ // Синхронные версии для обратной совместимости
+ void InstallApk(string apkPath);
+ void CopyFile(string localPath, string remotePath);
+}
\ No newline at end of file
diff --git a/MetaforceInstaller.Core/MetaforceInstaller.Core.csproj b/MetaforceInstaller.Core/MetaforceInstaller.Core.csproj
new file mode 100644
index 0000000..0a85bfc
--- /dev/null
+++ b/MetaforceInstaller.Core/MetaforceInstaller.Core.csproj
@@ -0,0 +1,26 @@
+
+
+
+ net8.0
+ enable
+ enable
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/MetaforceInstaller.Core/Models/ApkInfo.cs b/MetaforceInstaller.Core/Models/ApkInfo.cs
new file mode 100644
index 0000000..9f042bf
--- /dev/null
+++ b/MetaforceInstaller.Core/Models/ApkInfo.cs
@@ -0,0 +1,3 @@
+namespace MetaforceInstaller.Core.Models;
+
+public record ApkInfo(string PackageName, string VersionName, string VersionCode);
\ No newline at end of file
diff --git a/MetaforceInstaller.Core/Models/DeviceInfo.cs b/MetaforceInstaller.Core/Models/DeviceInfo.cs
new file mode 100644
index 0000000..802cd3c
--- /dev/null
+++ b/MetaforceInstaller.Core/Models/DeviceInfo.cs
@@ -0,0 +1,8 @@
+namespace MetaforceInstaller.Core.Models;
+
+public record DeviceInfo(
+ string SerialNumber,
+ string State,
+ string Model,
+ string Name
+);
\ No newline at end of file
diff --git a/MetaforceInstaller.Core/Models/InstallationRequest.cs b/MetaforceInstaller.Core/Models/InstallationRequest.cs
new file mode 100644
index 0000000..c64b271
--- /dev/null
+++ b/MetaforceInstaller.Core/Models/InstallationRequest.cs
@@ -0,0 +1,8 @@
+namespace MetaforceInstaller.Core.Models;
+
+public class InstallationRequest
+{
+ public string ApkPath { get; set; }
+ public string ZipPath { get; set; }
+ public string OutputPath { get; set; }
+}
\ No newline at end of file
diff --git a/MetaforceInstaller.Core/Models/ProgressInfo.cs b/MetaforceInstaller.Core/Models/ProgressInfo.cs
new file mode 100644
index 0000000..cc09a74
--- /dev/null
+++ b/MetaforceInstaller.Core/Models/ProgressInfo.cs
@@ -0,0 +1,19 @@
+namespace MetaforceInstaller.Core.Models;
+
+public class ProgressInfo
+{
+ public int PercentageComplete { get; set; }
+ public long BytesTransferred { get; set; }
+ public long TotalBytes { get; set; }
+ public string? Message { get; set; }
+ public string? CurrentFile { get; set; }
+ public ProgressType Type { get; set; }
+}
+
+public enum ProgressType
+{
+ Installation,
+ FileCopy,
+ Extraction,
+ General
+}
diff --git a/MetaforceInstaller.Core/Services/AdbService.cs b/MetaforceInstaller.Core/Services/AdbService.cs
new file mode 100644
index 0000000..ddf790f
--- /dev/null
+++ b/MetaforceInstaller.Core/Services/AdbService.cs
@@ -0,0 +1,206 @@
+using System.Reflection;
+using AdvancedSharpAdbClient;
+using AdvancedSharpAdbClient.DeviceCommands;
+using AdvancedSharpAdbClient.Models;
+using AdvancedSharpAdbClient.Receivers;
+using MetaforceInstaller.Core.Intefaces;
+using MetaforceInstaller.Core.Models;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Logging.Abstractions;
+
+namespace MetaforceInstaller.Core.Services;
+
+public class AdbService : IAdbService
+{
+ private readonly ILogger _logger;
+ private readonly AdbClient _adbClient;
+ private DeviceData _deviceData;
+
+ public event EventHandler? ProgressChanged;
+ public event EventHandler? StatusChanged;
+
+ public AdbService(ILogger? logger = null)
+ {
+ _logger = logger ?? new NullLogger();
+ var adbPath = GetAdbPath();
+ var server = new AdbServer();
+ var serverStatus = server.StartServer(adbPath, restartServerIfNewer: false);
+ _adbClient = new AdbClient();
+ RefreshDeviceData();
+ }
+
+ public void RefreshDeviceData()
+ {
+ var devices = _adbClient.GetDevices();
+ _deviceData = devices.FirstOrDefault();
+ }
+
+ private void ExtractResource(string resourceName, string outputPath)
+ {
+ _logger.LogInformation($"Extracting resource: {resourceName} to {outputPath}");
+ using var stream = Assembly.GetAssembly(typeof(AdbService)).GetManifestResourceStream(resourceName);
+ using var fileStream = File.Create(outputPath);
+ stream.CopyTo(fileStream);
+ _logger.LogInformation($"Resource extracted: {resourceName} to {outputPath}");
+ }
+
+ private string GetAdbPath()
+ {
+ var tempDir = Path.Combine(Path.GetTempPath(), "MetaforceInstaller", "adb");
+ Directory.CreateDirectory(tempDir);
+
+ var adbPath = Path.Combine(tempDir, "adb.exe");
+
+ if (File.Exists(adbPath)) return adbPath;
+ ExtractResource("MetaforceInstaller.Core.adb.adb.exe", adbPath);
+ ExtractResource("MetaforceInstaller.Core.adb.AdbWinApi.dll", Path.Combine(tempDir, "AdbWinApi.dll"));
+ ExtractResource("MetaforceInstaller.Core.adb.AdbWinUsbApi.dll", Path.Combine(tempDir, "AdbWinUsbApi.dll"));
+
+ return adbPath;
+ }
+
+ private void OnProgressChanged(ProgressInfo progressInfo)
+ {
+ ProgressChanged?.Invoke(this, progressInfo);
+ }
+
+ private void OnStatusChanged(string status)
+ {
+ StatusChanged?.Invoke(this, status);
+ }
+
+ public void InstallApk(string apkPath)
+ {
+ InstallApkAsync(apkPath).Wait();
+ }
+
+ public async Task InstallApkAsync(string apkPath, IProgress? progress = null,
+ CancellationToken cancellationToken = default)
+ {
+ try
+ {
+ if (!File.Exists(apkPath))
+ {
+ _logger.LogCritical("Error: Could not find APK file.");
+ return;
+ }
+
+ OnStatusChanged("Начинаем установку APK...");
+ _logger.LogInformation($"Installing APK: {apkPath}");
+
+ progress?.Report(new ProgressInfo
+ {
+ PercentageComplete = 0,
+ Message = "Подготовка к установке APK...",
+ Type = ProgressType.Installation,
+ CurrentFile = Path.GetFileName(apkPath)
+ });
+
+ var packageManager = new PackageManager(_adbClient, _deviceData);
+
+ await Task.Run(() =>
+ {
+ packageManager.InstallPackage(apkPath, installProgress =>
+ {
+ var progressInfo = new ProgressInfo
+ {
+ PercentageComplete = (int)installProgress.UploadProgress,
+ Message = $"Установка APK: {installProgress.UploadProgress:F1}%",
+ Type = ProgressType.Installation,
+ CurrentFile = Path.GetFileName(apkPath)
+ };
+
+ progress?.Report(progressInfo);
+ OnProgressChanged(progressInfo);
+ });
+ }, cancellationToken);
+
+ OnStatusChanged("APK успешно установлен!");
+ _logger.LogInformation("APK successfully installed!");
+ }
+ catch (Exception ex)
+ {
+ _logger.LogCritical($"Error: {ex.Message}");
+ throw;
+ }
+ }
+
+ public void CopyFile(string localPath, string remotePath)
+ {
+ CopyFileAsync(localPath, remotePath).Wait();
+ }
+
+ public async Task CopyFileAsync(string localPath, string remotePath, IProgress? progress = null,
+ CancellationToken cancellationToken = default)
+ {
+ try
+ {
+ if (!File.Exists(localPath))
+ {
+ _logger.LogCritical($"Error: Could not find file: {localPath}");
+ return;
+ }
+
+ OnStatusChanged("Начинаем копирование файла...");
+ _logger.LogInformation($"Copying file: {localPath} to {remotePath}");
+
+ var fileInfo = new FileInfo(localPath);
+
+ progress?.Report(new ProgressInfo
+ {
+ PercentageComplete = 0,
+ Message = "Подготовка к копированию файла...",
+ Type = ProgressType.FileCopy,
+ CurrentFile = Path.GetFileName(localPath),
+ TotalBytes = fileInfo.Length
+ });
+
+ var remoteDir = Path.GetDirectoryName(remotePath)?.Replace('\\', '/');
+ if (!string.IsNullOrEmpty(remoteDir))
+ {
+ var reciever = new ConsoleOutputReceiver();
+ await Task.Run(
+ () => { _adbClient.ExecuteRemoteCommand($"mkdir -p \"{remoteDir}\"", _deviceData, reciever); },
+ cancellationToken);
+ }
+
+ _logger.LogInformation($"Ensured remote directory: {remoteDir}");
+
+ await Task.Run(() =>
+ {
+ using var fileStream = File.OpenRead(localPath);
+ var syncService = new SyncService(_adbClient, _deviceData);
+
+ syncService.Push(fileStream, remotePath, UnixFileStatus.DefaultFileMode, DateTime.Now,
+ copyProgress =>
+ {
+ var progressInfo = new ProgressInfo
+ {
+ PercentageComplete = (int)copyProgress.ProgressPercentage,
+ BytesTransferred = copyProgress.ReceivedBytesSize,
+ TotalBytes = copyProgress.TotalBytesToReceive,
+ Message = $"Копирование: {copyProgress.ProgressPercentage:F1}%",
+ Type = ProgressType.FileCopy,
+ CurrentFile = Path.GetFileName(localPath)
+ };
+
+ progress?.Report(progressInfo);
+ OnProgressChanged(progressInfo);
+ });
+ }, cancellationToken);
+
+ OnStatusChanged("Файл успешно скопирован!");
+ _logger.LogInformation("File successfully copied!");
+ }
+ catch (Exception ex)
+ {
+ _logger.LogCritical($"Error: {ex.Message}");
+ throw;
+ }
+ }
+
+ public DeviceInfo GetDeviceInfo()
+ {
+ return new DeviceInfo(_deviceData.Serial, _deviceData.State.ToString(), _deviceData.Model, _deviceData.Name);
+ }
+}
\ No newline at end of file
diff --git a/MetaforceInstaller.Core/Services/ApkScrapper.cs b/MetaforceInstaller.Core/Services/ApkScrapper.cs
new file mode 100644
index 0000000..c06c543
--- /dev/null
+++ b/MetaforceInstaller.Core/Services/ApkScrapper.cs
@@ -0,0 +1,19 @@
+using AlphaOmega.Debug;
+using MetaforceInstaller.Core.Models;
+
+namespace MetaforceInstaller.Core.Services;
+
+public static class ApkScrapper
+{
+ public static ApkInfo GetApkInfo(string apkPath)
+ {
+ using var apk = new ApkFile(apkPath);
+ if (apk is { IsValid: true, AndroidManifest: not null })
+ {
+ return new ApkInfo(apk.AndroidManifest.Package, apk.AndroidManifest.VersionName,
+ apk.AndroidManifest.VersionCode);
+ }
+
+ throw new Exception("Invalid APK file");
+ }
+}
\ No newline at end of file
diff --git a/MetaforceInstaller.Core/adb/AdbWinApi.dll b/MetaforceInstaller.Core/adb/AdbWinApi.dll
new file mode 100644
index 0000000..7abe26c
Binary files /dev/null and b/MetaforceInstaller.Core/adb/AdbWinApi.dll differ
diff --git a/MetaforceInstaller.Core/adb/AdbWinUsbApi.dll b/MetaforceInstaller.Core/adb/AdbWinUsbApi.dll
new file mode 100644
index 0000000..e7a6de1
Binary files /dev/null and b/MetaforceInstaller.Core/adb/AdbWinUsbApi.dll differ
diff --git a/MetaforceInstaller.Core/adb/adb.exe b/MetaforceInstaller.Core/adb/adb.exe
new file mode 100644
index 0000000..85bfeaa
Binary files /dev/null and b/MetaforceInstaller.Core/adb/adb.exe differ
diff --git a/MetaforceInstaller.UI/App.axaml b/MetaforceInstaller.UI/App.axaml
new file mode 100644
index 0000000..d947dd9
--- /dev/null
+++ b/MetaforceInstaller.UI/App.axaml
@@ -0,0 +1,10 @@
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/MetaforceInstaller.UI/App.axaml.cs b/MetaforceInstaller.UI/App.axaml.cs
new file mode 100644
index 0000000..16212ac
--- /dev/null
+++ b/MetaforceInstaller.UI/App.axaml.cs
@@ -0,0 +1,23 @@
+using Avalonia;
+using Avalonia.Controls.ApplicationLifetimes;
+using Avalonia.Markup.Xaml;
+
+namespace MetaforceInstaller.UI;
+
+public partial class App : Application
+{
+ public override void Initialize()
+ {
+ AvaloniaXamlLoader.Load(this);
+ }
+
+ public override void OnFrameworkInitializationCompleted()
+ {
+ if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
+ {
+ desktop.MainWindow = new MainWindow();
+ }
+
+ base.OnFrameworkInitializationCompleted();
+ }
+}
\ No newline at end of file
diff --git a/MetaforceInstaller.UI/Images/logo_black.svg b/MetaforceInstaller.UI/Images/logo_black.svg
new file mode 100644
index 0000000..d41fcc9
--- /dev/null
+++ b/MetaforceInstaller.UI/Images/logo_black.svg
@@ -0,0 +1,16 @@
+
+
\ No newline at end of file
diff --git a/MetaforceInstaller.UI/Images/logo_red.svg b/MetaforceInstaller.UI/Images/logo_red.svg
new file mode 100644
index 0000000..1b73fdc
--- /dev/null
+++ b/MetaforceInstaller.UI/Images/logo_red.svg
@@ -0,0 +1,31 @@
+
+
+
diff --git a/MetaforceInstaller.UI/Images/logo_white.svg b/MetaforceInstaller.UI/Images/logo_white.svg
new file mode 100644
index 0000000..12ba8d1
--- /dev/null
+++ b/MetaforceInstaller.UI/Images/logo_white.svg
@@ -0,0 +1,23 @@
+
+
\ No newline at end of file
diff --git a/MetaforceInstaller.UI/MainWindow.axaml b/MetaforceInstaller.UI/MainWindow.axaml
new file mode 100644
index 0000000..61a426d
--- /dev/null
+++ b/MetaforceInstaller.UI/MainWindow.axaml
@@ -0,0 +1,70 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/MetaforceInstaller.UI/MainWindow.axaml.cs b/MetaforceInstaller.UI/MainWindow.axaml.cs
new file mode 100644
index 0000000..d6a49e1
--- /dev/null
+++ b/MetaforceInstaller.UI/MainWindow.axaml.cs
@@ -0,0 +1,230 @@
+using System;
+using System.IO;
+using System.Threading.Tasks;
+using Avalonia.Controls;
+using Avalonia.Interactivity;
+using Avalonia.Platform.Storage;
+using Avalonia.Threading;
+using Avalonia.VisualTree;
+using MetaforceInstaller.Core.Models;
+using MetaforceInstaller.Core.Services;
+
+namespace MetaforceInstaller.UI;
+
+public partial class MainWindow : Window
+{
+ private string? _apkPath;
+ private string? _zipPath;
+ private AdbService _adbService;
+
+ private const int PROGRESS_LOG_STEP = 10;
+ private const int PROGRESS_UPDATE_STEP = 1;
+
+ private int _lastLoggedProgress = -1;
+ private int _lastUpdatedProgress = -1;
+
+ public MainWindow()
+ {
+ InitializeComponent();
+
+ LogMessage("MetaforceInstaller by slavagm");
+
+ _adbService = new AdbService();
+ _adbService.ProgressChanged += OnAdbProgressChanged;
+ _adbService.StatusChanged += OnAdbStatusChanged;
+
+ CheckAndEnableInstallButton();
+
+ ChooseApkButton.Click += OnChooseApkClicked;
+ ChooseContentButton.Click += OnChooseContentClicked;
+ InstallButton.Click += OnInstallClicked;
+ }
+
+ private void OnAdbProgressChanged(object? sender, ProgressInfo e)
+ {
+ Dispatcher.UIThread.InvokeAsync(() =>
+ {
+ if (e.PercentageComplete != _lastUpdatedProgress &&
+ e.PercentageComplete % PROGRESS_UPDATE_STEP == 0)
+ {
+ InstallProgressBar.Value = e.PercentageComplete;
+ _lastUpdatedProgress = e.PercentageComplete;
+ }
+
+ if (e.PercentageComplete != _lastLoggedProgress &&
+ e.PercentageComplete % PROGRESS_LOG_STEP == 0 || e.PercentageComplete == 100)
+ {
+ LogMessage(
+ e.TotalBytes > 0
+ ? $"Прогресс: {e.PercentageComplete}% ({FormatBytes(e.BytesTransferred)} / {FormatBytes(e.TotalBytes)})"
+ : $"Прогресс: {e.PercentageComplete}%");
+
+ _lastLoggedProgress = e.PercentageComplete;
+ }
+ });
+ }
+
+ private void OnProgressReport(ProgressInfo progressInfo)
+ {
+ Dispatcher.UIThread.InvokeAsync(() =>
+ {
+ if (progressInfo.PercentageComplete != _lastUpdatedProgress &&
+ progressInfo.PercentageComplete % PROGRESS_UPDATE_STEP == 0)
+ {
+ InstallProgressBar.Value = progressInfo.PercentageComplete;
+ _lastUpdatedProgress = progressInfo.PercentageComplete;
+ }
+
+
+ if (progressInfo.PercentageComplete != _lastLoggedProgress &&
+ (progressInfo.PercentageComplete % PROGRESS_LOG_STEP == 0 || progressInfo.PercentageComplete == 100))
+ {
+ LogMessage(
+ progressInfo.TotalBytes > 0
+ ? $"Прогресс: {progressInfo.PercentageComplete}% ({FormatBytes(progressInfo.BytesTransferred)} / {FormatBytes(progressInfo.TotalBytes)})"
+ : $"Прогресс: {progressInfo.PercentageComplete}%");
+
+ _lastLoggedProgress = progressInfo.PercentageComplete;
+ }
+ });
+ }
+
+ private void OnAdbStatusChanged(object? sender, string e)
+ {
+ Dispatcher.UIThread.InvokeAsync(() =>
+ {
+ InstallProgressBar.Value = 0;
+ _lastLoggedProgress = -1;
+ _lastUpdatedProgress = -1;
+ LogMessage(e);
+ });
+ }
+
+ private string FormatBytes(long bytes)
+ {
+ string[] suffixes = ["B", "KB", "MB", "GB", "TB"];
+ var counter = 0;
+ double number = bytes;
+
+ while (Math.Round(number / 1024) >= 1)
+ {
+ number /= 1024;
+ counter++;
+ }
+
+ return $"{number:N1} {suffixes[counter]}";
+ }
+
+
+ private async void CheckAndEnableInstallButton()
+ {
+ InstallButton.IsEnabled = !string.IsNullOrEmpty(_apkPath) && !string.IsNullOrEmpty(_zipPath);
+ }
+
+ private async void OnChooseApkClicked(object? sender, RoutedEventArgs e)
+ {
+ var topLevel = GetTopLevel(this);
+ var files = await topLevel!.StorageProvider.OpenFilePickerAsync(new FilePickerOpenOptions
+ {
+ Title = "Выберите APK файл",
+ AllowMultiple = false,
+ FileTypeFilter = new[]
+ {
+ new FilePickerFileType("APK Files")
+ {
+ Patterns = new[] { "*.apk" }
+ }
+ }
+ });
+
+ if (files.Count >= 1)
+ {
+ _apkPath = files[0].Path.LocalPath;
+ LogMessage($"APK выбран: {Path.GetFileName(_apkPath)}");
+ }
+
+ CheckAndEnableInstallButton();
+ }
+
+ private async void OnChooseContentClicked(object? sender, RoutedEventArgs e)
+ {
+ var topLevel = GetTopLevel(this);
+ var files = await topLevel!.StorageProvider.OpenFilePickerAsync(new FilePickerOpenOptions
+ {
+ Title = "Выберите архив с контентом",
+ AllowMultiple = false,
+ FileTypeFilter = new[]
+ {
+ new FilePickerFileType("ZIP Files")
+ {
+ Patterns = new[] { "*.zip" }
+ }
+ }
+ });
+
+ if (files.Count >= 1)
+ {
+ _zipPath = files[0].Path.LocalPath;
+ LogMessage($"Контент выбран: {Path.GetFileName(_zipPath)}");
+ }
+
+ CheckAndEnableInstallButton();
+ }
+
+ private async void OnInstallClicked(object? sender, RoutedEventArgs e)
+ {
+ if (string.IsNullOrEmpty(_apkPath) || string.IsNullOrEmpty(_zipPath))
+ {
+ LogMessage("Ошибка: Выберите APK файл и папку с контентом");
+ return;
+ }
+
+ _adbService.RefreshDeviceData();
+
+ InstallButton.IsEnabled = false;
+ InstallProgressBar.Value = 0;
+
+ try
+ {
+ LogMessage("Начинаем установку...");
+
+ var deviceInfo = _adbService.GetDeviceInfo();
+ LogMessage($"Найдено устройство: {deviceInfo.SerialNumber}");
+ LogMessage($"Состояние: {deviceInfo.State}");
+ LogMessage($"Модель: {deviceInfo.Model} - {deviceInfo.Name}");
+
+ var progress = new Progress(OnProgressReport);
+
+ await _adbService.InstallApkAsync(_apkPath, progress);
+
+ var apkInfo = ApkScrapper.GetApkInfo(_apkPath);
+ LogMessage($"Ставим {apkInfo.PackageName} версии {apkInfo.VersionName}");
+ var zipName = Path.GetFileName(_zipPath);
+ var outputPath =
+ @$"/storage/emulated/0/Android/data/{apkInfo.PackageName}/files/{zipName}";
+ LogMessage($"Начинаем копирование контента в {outputPath}");
+
+ await _adbService.CopyFileAsync(_zipPath, outputPath, progress);
+
+ LogMessage("Установка завершена успешно!");
+ }
+ catch (Exception ex)
+ {
+ LogMessage($"Ошибка установки: {ex.Message}");
+ }
+ finally
+ {
+ InstallButton.IsEnabled = true;
+ InstallProgressBar.Value = 0;
+ }
+ }
+
+ private void LogMessage(string message)
+ {
+ var timestamp = DateTime.Now.ToString("HH:mm:ss");
+ LogsTextBox.Text += $"[{timestamp}] {message}\n";
+
+ var scrollViewer = LogsTextBox.FindAncestorOfType();
+ scrollViewer?.ScrollToEnd();
+ }
+}
\ No newline at end of file
diff --git a/MetaforceInstaller.UI/MetaforceInstaller.UI.csproj b/MetaforceInstaller.UI/MetaforceInstaller.UI.csproj
new file mode 100644
index 0000000..26ffaf3
--- /dev/null
+++ b/MetaforceInstaller.UI/MetaforceInstaller.UI.csproj
@@ -0,0 +1,37 @@
+
+
+ WinExe
+ net8.0
+ enable
+ true
+ true
+ true
+ app.manifest
+ true
+ 1.2.1
+
+
+
+
+
+
+
+
+
+ None
+ All
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/MetaforceInstaller.UI/Program.cs b/MetaforceInstaller.UI/Program.cs
new file mode 100644
index 0000000..c77ddc1
--- /dev/null
+++ b/MetaforceInstaller.UI/Program.cs
@@ -0,0 +1,21 @@
+using Avalonia;
+using System;
+
+namespace MetaforceInstaller.UI;
+
+class Program
+{
+ // Initialization code. Don't use any Avalonia, third-party APIs or any
+ // SynchronizationContext-reliant code before AppMain is called: things aren't initialized
+ // yet and stuff might break.
+ [STAThread]
+ public static void Main(string[] args) => BuildAvaloniaApp()
+ .StartWithClassicDesktopLifetime(args);
+
+ // Avalonia configuration, don't remove; also used by visual designer.
+ public static AppBuilder BuildAvaloniaApp()
+ => AppBuilder.Configure()
+ .UsePlatformDetect()
+ .WithInterFont()
+ .LogToTrace();
+}
\ No newline at end of file
diff --git a/MetaforceInstaller.UI/app.manifest b/MetaforceInstaller.UI/app.manifest
new file mode 100644
index 0000000..0543ed4
--- /dev/null
+++ b/MetaforceInstaller.UI/app.manifest
@@ -0,0 +1,18 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+