#requires -Version 5 param( [string]$RepoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..\..")).Path, [string]$BuildType = "release", [string]$AndroidHome = $env:ANDROID_HOME, [switch]$SkipWorkspaceCleanup, [switch]$PrintOnly, [bool]$PublishToTestDockerDownloads = $false, [string]$TestDockerSshAlias = "test-docker", [string]$TestDockerDownloadPath = "/tmp/rap-web-admin/html/downloads", [string]$TestDockerBackendReleasePath = "/tmp/rap-release-0.2.309-latencyaware" ) $ErrorActionPreference = "Stop" function Fail([string]$Message) { Write-Error $Message exit 1 } function Resolve-AndroidSdk([string]$CandidateHome) { $candidates = @( $CandidateHome, "C:\Android\Sdk", "$env:LOCALAPPDATA\Android\Sdk", "$env:USERPROFILE\AppData\Local\Android\Sdk" ) | Where-Object { $_ -and (Test-Path $_) } | Select-Object -Unique foreach ($candidate in $candidates) { if ((Test-Path (Join-Path $candidate "platform-tools")) -and (Test-Path (Join-Path $candidate "platforms"))) { return $candidate } } if ($candidates.Count -gt 0) { return $candidates[0] } return $null } function Get-GradleBinary { $gradleCandidate = Get-Command gradle -ErrorAction SilentlyContinue if ($null -eq $gradleCandidate) { Fail "Gradle не найден в PATH. Установи Gradle (`winget install Gradle.Gradle` или `choco install gradle`)." } return $gradleCandidate.Source } function Get-JavaVersion { $javaCandidate = Get-Command java -ErrorAction SilentlyContinue if ($null -eq $javaCandidate) { Fail "Java не найден в PATH. Нужен JDK 17+." } $escapedJava = $javaCandidate.Source.Replace('"', '\"') $versionLine = & cmd.exe /c "`"$escapedJava`" -version 2>&1" | Select-Object -First 1 if ($versionLine -match "version\s+`"(\d+)\.") { return $matches[1] } return "unknown" } function Resolve-SdkComponent([string]$Root, [string]$RelativePath) { $candidate = Join-Path $Root $RelativePath if (Test-Path $candidate) { return $candidate } return $null } function Get-AndroidVersionInfo([string]$AndroidProjectDir) { $moduleBuildFile = Join-Path $AndroidProjectDir "app\build.gradle" if (-not (Test-Path $moduleBuildFile)) { Fail "Не найден файл app/build.gradle: $moduleBuildFile" } $content = Get-Content $moduleBuildFile -Raw $versionCodeMatch = [regex]::Match($content, '(?m)^\s*versionCode\s+([0-9]+)\s*$') $versionNameMatch = [regex]::Match($content, '(?m)^\s*versionName\s+"([^"]+)"\s*$') if (-not $versionCodeMatch.Success -or -not $versionNameMatch.Success) { Fail "Не удалось прочитать versionCode/versionName из app/build.gradle" } return @{ VersionCode = [int]$versionCodeMatch.Groups[1].Value VersionName = $versionNameMatch.Groups[1].Value.Trim() } } function Publish-Artifact { param( [string]$SourcePath, [string]$PublishRoot, [string]$BuildTypeNormalized, [string]$VersionName, [string]$VersionFileName, [string]$LatestFileName, [string]$PublishedPathPrefix, [string]$VersionTag ) New-Item -ItemType Directory -Force -Path $PublishRoot | Out-Null $versionDir = Join-Path $PublishRoot "releases\$VersionName" New-Item -ItemType Directory -Force -Path $versionDir | Out-Null $latestPath = Join-Path $PublishRoot $LatestFileName $versionPath = Join-Path $versionDir $VersionFileName $metaPath = Join-Path $PublishRoot "rap-android-vpn-build.json" Copy-Item -Path $SourcePath -Destination $latestPath -Force Copy-Item -Path $SourcePath -Destination $versionPath -Force $hash = (Get-FileHash -Algorithm SHA256 -Path $SourcePath).Hash.ToLowerInvariant() $size = (Get-Item $SourcePath).Length $published = [ordered]@{ version = @{ name = $VersionName code = $VersionTag } build = @{ type = $BuildTypeNormalized artifact = "app-$BuildTypeNormalized.apk" } published = @{ timestamp_utc = (Get-Date).ToUniversalTime().ToString("o") path = ("$PublishedPathPrefix/$LatestFileName").TrimStart("/") size_bytes = $size sha256 = $hash } release_paths = @{ latest = $LatestFileName versioned = "releases/$VersionName/$VersionFileName" } } $manifestJson = ($published | ConvertTo-Json -Depth 6) + [Environment]::NewLine $utf8NoBom = New-Object System.Text.UTF8Encoding($false) [System.IO.File]::WriteAllText($metaPath, $manifestJson, $utf8NoBom) return @{ latest = $latestPath versioned = $versionPath summary = $metaPath hash = $hash size = $size artifacts = @( $latestPath, $versionPath, $metaPath ) releaseDir = $versionDir } } function Resolve-RemoteDirectory([string]$RemotePath) { return $RemotePath.Trim() } function Ensure-RemoteDirectory { param( [string]$RemoteHost, [string]$RemotePath ) if (-not $RemoteHost -or -not $RemotePath) { Fail "Remote host/path must be set for publish sync." } $escapedPath = $RemotePath.Replace("'", "''") & ssh $RemoteHost "mkdir -p '$escapedPath'" | Out-Null if ($LASTEXITCODE -ne 0) { Fail "Не удалось создать каталог на remote хосте: $RemotePath" } } function Publish-ToTestDockerDownloads { param( [string]$RemoteHost, [string]$RemoteRoot, [string]$VersionName, [string]$LatestFile, [string]$VersionFile, [string]$ManifestFile ) Ensure-RemoteDirectory -RemoteHost $RemoteHost -RemotePath $RemoteRoot $remoteVersionDir = "$RemoteRoot/releases/$VersionName" Ensure-RemoteDirectory -RemoteHost $RemoteHost -RemotePath $remoteVersionDir & scp -q $LatestFile "${RemoteHost}:$($RemoteRoot)/" | Out-Null if ($LASTEXITCODE -ne 0) { Fail "Не удалось скопировать latest APK на remote host: ${RemoteHost}:$RemoteRoot" } & scp -q $ManifestFile "${RemoteHost}:$($RemoteRoot)/" | Out-Null if ($LASTEXITCODE -ne 0) { Fail "Не удалось скопировать манифест на remote host: ${RemoteHost}:$RemoteRoot" } & scp -q $VersionFile "${RemoteHost}:$($remoteVersionDir)/" | Out-Null if ($LASTEXITCODE -ne 0) { Fail "Не удалось скопировать versioned APK на remote host: ${RemoteHost}:$remoteVersionDir" } Write-Host "SCP на ${RemoteHost}:${RemoteRoot} выполнен." } function Resolve-AbsoluteProjectPath([string]$path) { if ([string]::IsNullOrWhiteSpace($path)) { return $null } if (Test-Path $path) { return (Resolve-Path $path).ProviderPath } return $null } $projectDir = Resolve-AbsoluteProjectPath $RepoRoot if (-not $projectDir) { Fail "RepoRoot не найден: $RepoRoot" } $androidProject = [System.IO.Path]::GetFullPath((Join-Path $projectDir "clients\android")) if (-not (Test-Path $androidProject)) { Fail "Не найден Android проект: $androidProject" } $sdkRoot = Resolve-AndroidSdk $AndroidHome if (-not $sdkRoot) { Fail "Android SDK не найден. Укажи -AndroidHome или установи SDK в C:\Android\Sdk." } $cmdlineTools = Resolve-SdkComponent $sdkRoot "cmdline-tools\latest" if (-not $cmdlineTools) { Fail "Android cmdline-tools не найдены. Установи Android SDK command-line tools." } if (-not $env:ANDROID_HOME -or $env:ANDROID_HOME -ne $sdkRoot) { Write-Host "Устанавливаю переменные ANDROID_HOME/ANDROID_SDK_ROOT в текущей сессии: $sdkRoot" $env:ANDROID_HOME = $sdkRoot } $env:ANDROID_SDK_ROOT = $sdkRoot $gradleBinary = Get-GradleBinary $javaMajor = Get-JavaVersion if ($javaMajor -ne "unknown" -and [int]$javaMajor -lt 17) { Fail "Найден Java $javaMajor, нужен JDK 17+" } $versionInfo = Get-AndroidVersionInfo $androidProject $versionName = $versionInfo.VersionName if ([string]::IsNullOrWhiteSpace($versionName)) { $versionName = "0" } $versionCode = $versionInfo.VersionCode if ($versionCode -le 0) { $versionCode = 0 } $buildTypeNormalized = $BuildType.Trim().ToLower() if ($buildTypeNormalized -notin @("debug","release")) { Fail "BuildType должен быть debug или release" } if (-not $PrintOnly -and $buildTypeNormalized -eq "release" -and -not $PublishToTestDockerDownloads) { Fail "Release сборка запрещена без публикации артефактов. Передай -PublishToTestDockerDownloads:`$true или запускай release-android-apk.ps1/fast-release-android-apk.ps1." } $buildTask = "assemble" + (Get-Culture).TextInfo.ToTitleCase($buildTypeNormalized) Write-Host "=== RAP Android сборка APK ===" Write-Host "Проект: $androidProject" Write-Host "Версия: $versionName ($versionCode)" Write-Host "SDK: $sdkRoot" Write-Host "Gradle: $gradleBinary" Write-Host "Build: $buildTask" Write-Host "Build task: $buildTask" if (-not (Test-Path (Join-Path $sdkRoot "platforms\android-35"))) { Write-Host "Внимание: не найдено платформы android-35 в $sdkRoot\platforms. Проверь install: platforms;android-35." } if (-not (Test-Path (Join-Path $sdkRoot "build-tools\35.0.1"))) { Write-Host "Внимание: не найдено build-tools 35.0.1 в $sdkRoot\build-tools." } $workspace = Join-Path $env:TEMP ("rap-android-build-" + [guid]::NewGuid().ToString("N")) if (-not (Test-Path "C:\\")) { Write-Host "Каталог C:\ недоступен. Использую TEMP: $env:TEMP" } else { $candidateWorkspace = Join-Path "C:\" ("rap-android-build-" + [guid]::NewGuid().ToString("N")) $candidateParent = Split-Path $candidateWorkspace -Parent if (Test-Path $candidateParent) { $workspace = $candidateWorkspace } } New-Item -ItemType Directory -Path $workspace -Force | Out-Null Write-Host "Временная рабочая папка: $workspace" $buildSucceeded = $false $builtApk = $null $summary = $null try { Write-Host "Копирую проект в локальную рабочую папку (избавление от проблем UNC)." Copy-Item -Path (Join-Path $androidProject "*") -Destination $workspace -Recurse -Force if ($PrintOnly) { Write-Host "PrintOnly: сборка не выполнена." $global:LASTEXITCODE = 0 return } $originalLocation = Get-Location try { Push-Location $workspace $resolvedLocalProperties = Join-Path $workspace "local.properties" if (Test-Path $resolvedLocalProperties) { Remove-Item -Force $resolvedLocalProperties } Write-Host "Запускаю сборку..." & $gradleBinary $buildTask if ($LASTEXITCODE -ne 0) { Fail "Gradle сборка завершилась с кодом $LASTEXITCODE" } } finally { Set-Location $originalLocation } $builtApkDir = Join-Path $workspace "app\build\outputs\apk\$buildTypeNormalized" $candidateApks = @() if (Test-Path $builtApkDir) { $candidateApks = Get-ChildItem -Path $builtApkDir -Filter "app-${buildTypeNormalized}*.apk" -File -ErrorAction SilentlyContinue | Sort-Object LastWriteTime -Descending | Select-Object -ExpandProperty FullName } if (-not $candidateApks -or $candidateApks.Count -eq 0) { $fallbackApk = Join-Path $builtApkDir "app-$buildTypeNormalized.apk" if (Test-Path $fallbackApk) { $candidateApks = @($fallbackApk) } else { $candidateApks = @(Get-ChildItem -Path (Join-Path $workspace "app\build\outputs\apk") -Recurse -Filter "app-*.apk" -File -ErrorAction SilentlyContinue | Sort-Object LastWriteTime -Descending | Select-Object -ExpandProperty FullName) } } $builtApk = $candidateApks | Select-Object -First 1 if (-not $builtApk) { $expectedPath = Join-Path $builtApkDir "app-${buildTypeNormalized}.apk" Fail "Сборка не дала APK: $expectedPath" } $buildSucceeded = $true $latestFileName = "rap-android-vpn-latest-$buildTypeNormalized.apk" $versionFileName = "rap-android-vpn-$versionName-$buildTypeNormalized.apk" $publishedPathPrefix = "downloads" $publishDirs = @( [System.IO.Path]::GetFullPath((Join-Path $projectDir "dist\downloads")), [System.IO.Path]::GetFullPath((Join-Path $projectDir "web-admin\deploy\html\downloads")) ) $publishResults = @() foreach ($publishDir in $publishDirs) { $result = Publish-Artifact -SourcePath $builtApk ` -PublishRoot $publishDir ` -BuildTypeNormalized $buildTypeNormalized ` -VersionName $versionName ` -VersionFileName $versionFileName ` -LatestFileName $latestFileName ` -PublishedPathPrefix $publishedPathPrefix ` -VersionTag $versionCode $publishResults += $result Write-Host "Опубликован в: $publishDir" } $summary = $publishResults[0] if ($PublishToTestDockerDownloads) { $remotePublishSource = if ($publishResults.Count -ge 2) { $publishResults[1] } else { $summary } Write-Host "Публикую артефакты в test-docker ($TestDockerSshAlias): $TestDockerDownloadPath" Publish-ToTestDockerDownloads -RemoteHost $TestDockerSshAlias -RemoteRoot (Resolve-RemoteDirectory $TestDockerDownloadPath) ` -VersionName $versionName ` -LatestFile $remotePublishSource.latest ` -VersionFile $remotePublishSource.versioned ` -ManifestFile $remotePublishSource.summary if (-not [string]::IsNullOrWhiteSpace($TestDockerBackendReleasePath) -and $TestDockerBackendReleasePath -ne $TestDockerDownloadPath) { Write-Host "Публикую артефакты в backend RAP_RELEASE_DIR ($TestDockerSshAlias): $TestDockerBackendReleasePath" Publish-ToTestDockerDownloads -RemoteHost $TestDockerSshAlias -RemoteRoot (Resolve-RemoteDirectory $TestDockerBackendReleasePath) ` -VersionName $versionName ` -LatestFile $remotePublishSource.latest ` -VersionFile $remotePublishSource.versioned ` -ManifestFile $remotePublishSource.summary } } Write-Host "Сборка завершена." Write-Host "APK: $($summary.latest)" Write-Host "Версионная копия: $($summary.versioned)" Write-Host "Размер: $($summary.size) байт" Write-Host "SHA256: $($summary.hash)" } finally { if (-not $SkipWorkspaceCleanup -and (Test-Path $workspace)) { Remove-Item -Recurse -Force $workspace } } if (-not $buildSucceeded) { Fail "Сборка не завершена." } $global:LASTEXITCODE = 0