356 lines
13 KiB
PowerShell
356 lines
13 KiB
PowerShell
param(
|
|
[string]$Slug = 'qsfera-mobile',
|
|
[string]$Name,
|
|
[string]$Summary = 'Android client for QSfera server.',
|
|
[string]$Description = 'Android client for QSfera server, prepared for Argus publication.',
|
|
[string]$Version,
|
|
[string]$Channel = 'stable',
|
|
[string]$Platform = 'android',
|
|
[string]$Notes,
|
|
[string]$RepositoryUrl = 'https://git.kusoft.xyz/sevenhill/QSfera.git',
|
|
[string]$HomepageUrl = 'https://qsfera.kusoft.xyz',
|
|
[string]$GitProvenanceRoot,
|
|
[switch]$Unlisted,
|
|
[switch]$SkipSigningInitialization,
|
|
[switch]$SkipGitProvenanceCheck
|
|
)
|
|
|
|
$ErrorActionPreference = 'Stop'
|
|
|
|
function Require-Tool([string]$Path, [string]$Label) {
|
|
if (-not (Test-Path $Path)) {
|
|
throw "$Label was not found: $Path"
|
|
}
|
|
}
|
|
|
|
function Get-ApkBadgingValue([string]$BadgingText, [string]$Pattern) {
|
|
$match = [regex]::Match($BadgingText, $Pattern, [System.Text.RegularExpressions.RegexOptions]::Multiline)
|
|
if (-not $match.Success) {
|
|
return $null
|
|
}
|
|
return $match.Groups[1].Value
|
|
}
|
|
|
|
function Read-Properties([string]$Path) {
|
|
$result = @{}
|
|
foreach ($line in Get-Content $Path) {
|
|
$trimmed = $line.Trim()
|
|
if ($trimmed.Length -eq 0 -or $trimmed.StartsWith('#')) {
|
|
continue
|
|
}
|
|
|
|
$separator = $trimmed.IndexOf('=')
|
|
if ($separator -le 0) {
|
|
continue
|
|
}
|
|
|
|
$key = $trimmed.Substring(0, $separator).Trim()
|
|
$value = $trimmed.Substring($separator + 1).Trim()
|
|
$result[$key] = $value
|
|
}
|
|
return $result
|
|
}
|
|
|
|
function Resolve-JavaHome {
|
|
$candidates = @(
|
|
$env:JAVA_HOME,
|
|
'C:\Program Files\Android\Android Studio\jbr',
|
|
'C:\Program Files\Android\openjdk\jdk-21.0.8'
|
|
) | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }
|
|
|
|
foreach ($candidate in $candidates) {
|
|
$java = Join-Path $candidate 'bin\java.exe'
|
|
if (Test-Path $java) {
|
|
return $candidate
|
|
}
|
|
}
|
|
|
|
throw 'java.exe was not found. Set JAVA_HOME to a JDK path.'
|
|
}
|
|
|
|
function Resolve-AndroidHome {
|
|
$candidates = @(
|
|
$env:ANDROID_HOME,
|
|
"$env:LOCALAPPDATA\Android\Sdk"
|
|
) | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }
|
|
|
|
foreach ($candidate in $candidates) {
|
|
if (Test-Path (Join-Path $candidate 'build-tools')) {
|
|
return $candidate
|
|
}
|
|
}
|
|
|
|
throw 'Android SDK was not found. Set ANDROID_HOME to the SDK path.'
|
|
}
|
|
|
|
function Resolve-ArgusPlatform([string[]]$NativeCodes) {
|
|
$normalized = $NativeCodes | Where-Object { $_ } | ForEach-Object { $_.Trim().ToLowerInvariant() }
|
|
$allUniversalAbis = @('arm64-v8a', 'armeabi-v7a', 'x86', 'x86_64')
|
|
if (@($allUniversalAbis | Where-Object { $normalized -contains $_ }).Count -eq $allUniversalAbis.Count) {
|
|
return 'android-universal'
|
|
}
|
|
if ($normalized -contains 'arm64-v8a') {
|
|
return 'android-arm64'
|
|
}
|
|
if ($normalized -contains 'armeabi-v7a') {
|
|
return 'android-armv7'
|
|
}
|
|
if ($normalized -contains 'x86_64') {
|
|
return 'android-x86_64'
|
|
}
|
|
if ($normalized -contains 'x86') {
|
|
return 'android-x86'
|
|
}
|
|
return 'android'
|
|
}
|
|
|
|
function Find-FirstTool([string]$AndroidHome, [string]$ToolName) {
|
|
$tool = Get-ChildItem -Path (Join-Path $AndroidHome 'build-tools') -Recurse -File -Filter $ToolName |
|
|
Sort-Object FullName -Descending |
|
|
Select-Object -First 1
|
|
if (-not $tool) {
|
|
throw "$ToolName was not found under Android build-tools."
|
|
}
|
|
return $tool.FullName
|
|
}
|
|
|
|
function Invoke-Git([string[]]$Arguments) {
|
|
$output = & git @Arguments 2>&1
|
|
if ($LASTEXITCODE -ne 0) {
|
|
throw "git $($Arguments -join ' ') failed with exit code $LASTEXITCODE. Output: $($output | Out-String)"
|
|
}
|
|
return @($output)
|
|
}
|
|
|
|
function Assert-GitReleaseProvenance([string]$RepositoryRoot) {
|
|
if (-not (Get-Command git -ErrorAction SilentlyContinue)) {
|
|
throw 'git was not found. Argus publication requires git provenance verification.'
|
|
}
|
|
|
|
$previousLocation = Get-Location
|
|
try {
|
|
Set-Location $RepositoryRoot
|
|
|
|
$insideWorkTree = (Invoke-Git @('rev-parse', '--is-inside-work-tree') | Select-Object -First 1).Trim()
|
|
if ($insideWorkTree -ne 'true') {
|
|
throw 'Argus publication must be prepared from inside a git work tree.'
|
|
}
|
|
|
|
$status = Invoke-Git @('status', '--porcelain')
|
|
if ($status.Count -gt 0) {
|
|
throw "Git work tree has uncommitted changes. Commit or stash all changes before preparing Argus publication."
|
|
}
|
|
|
|
$branch = (Invoke-Git @('rev-parse', '--abbrev-ref', 'HEAD') | Select-Object -First 1).Trim()
|
|
if ($branch -eq 'HEAD') {
|
|
throw 'Git HEAD is detached. Check out the release branch before preparing Argus publication.'
|
|
}
|
|
|
|
try {
|
|
$upstream = (Invoke-Git @('rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{u}') | Select-Object -First 1).Trim()
|
|
} catch {
|
|
throw "Git branch has no upstream. Push $branch and set its upstream before Argus publication."
|
|
}
|
|
|
|
Invoke-Git @('fetch', '--quiet')
|
|
|
|
$aheadBehind = (Invoke-Git @('rev-list', '--left-right', '--count', 'HEAD...@{u}') | Select-Object -First 1).Trim() -split '\s+'
|
|
$ahead = [int]$aheadBehind[0]
|
|
$behind = [int]$aheadBehind[1]
|
|
if ($ahead -ne 0 -or $behind -ne 0) {
|
|
throw "Git HEAD must match upstream before Argus publication. Branch $branch tracks $upstream, ahead=$ahead, behind=$behind."
|
|
}
|
|
|
|
return [ordered]@{
|
|
repositoryRoot = (Invoke-Git @('rev-parse', '--show-toplevel') | Select-Object -First 1).Trim()
|
|
branch = $branch
|
|
upstream = $upstream
|
|
commit = (Invoke-Git @('rev-parse', 'HEAD') | Select-Object -First 1).Trim()
|
|
verifiedAt = [DateTimeOffset]::UtcNow.ToString('O')
|
|
}
|
|
} finally {
|
|
Set-Location $previousLocation
|
|
}
|
|
}
|
|
|
|
$repoRoot = Split-Path -Parent $PSScriptRoot
|
|
Set-Location $repoRoot
|
|
$gitProvenanceRootPath = if ($GitProvenanceRoot) {
|
|
(Resolve-Path -LiteralPath $GitProvenanceRoot).Path
|
|
} else {
|
|
$repoRoot
|
|
}
|
|
|
|
$gitProvenance = if ($SkipGitProvenanceCheck) {
|
|
[ordered]@{
|
|
skipped = $true
|
|
repositoryRoot = $gitProvenanceRootPath
|
|
reason = 'SkipGitProvenanceCheck was set'
|
|
verifiedAt = [DateTimeOffset]::UtcNow.ToString('O')
|
|
}
|
|
} else {
|
|
Assert-GitReleaseProvenance $gitProvenanceRootPath
|
|
}
|
|
|
|
$javaHome = Resolve-JavaHome
|
|
$androidHome = Resolve-AndroidHome
|
|
$env:JAVA_HOME = $javaHome
|
|
$env:ANDROID_HOME = $androidHome
|
|
|
|
$gradlePath = Join-Path $repoRoot 'gradlew.bat'
|
|
$aaptPath = Find-FirstTool $androidHome 'aapt.exe'
|
|
$apksignerPath = Find-FirstTool $androidHome 'apksigner.bat'
|
|
$signingPropertiesPath = Join-Path $repoRoot 'argus-signing.local.properties'
|
|
$signingInitScript = Join-Path $PSScriptRoot 'new-argus-upload-keystore.ps1'
|
|
|
|
Require-Tool $gradlePath 'Gradle wrapper'
|
|
Require-Tool $aaptPath 'aapt'
|
|
Require-Tool $apksignerPath 'apksigner'
|
|
Require-Tool $signingInitScript 'Argus signing init script'
|
|
|
|
if (-not (Test-Path $signingPropertiesPath)) {
|
|
if ($SkipSigningInitialization) {
|
|
throw "Signing properties were not found: $signingPropertiesPath"
|
|
}
|
|
& $signingInitScript
|
|
if ($LASTEXITCODE -ne 0) {
|
|
throw "Signing initialization failed with exit code $LASTEXITCODE."
|
|
}
|
|
}
|
|
|
|
$signingProperties = Read-Properties $signingPropertiesPath
|
|
foreach ($key in @(
|
|
'QSFERA_RELEASE_KEYSTORE',
|
|
'QSFERA_RELEASE_KEYSTORE_PASSWORD',
|
|
'QSFERA_RELEASE_KEY_ALIAS',
|
|
'QSFERA_RELEASE_KEY_PASSWORD'
|
|
)) {
|
|
if (-not $signingProperties.ContainsKey($key) -or [string]::IsNullOrWhiteSpace($signingProperties[$key])) {
|
|
throw "Signing property is missing: $key"
|
|
}
|
|
}
|
|
|
|
$env:QSFERA_RELEASE_KEYSTORE = $signingProperties['QSFERA_RELEASE_KEYSTORE']
|
|
$env:QSFERA_RELEASE_KEYSTORE_PASSWORD = $signingProperties['QSFERA_RELEASE_KEYSTORE_PASSWORD']
|
|
$env:QSFERA_RELEASE_KEY_ALIAS = $signingProperties['QSFERA_RELEASE_KEY_ALIAS']
|
|
$env:QSFERA_RELEASE_KEY_PASSWORD = $signingProperties['QSFERA_RELEASE_KEY_PASSWORD']
|
|
|
|
& $gradlePath :qsferaApp:assembleOriginalRelease --console=plain
|
|
if ($LASTEXITCODE -ne 0) {
|
|
throw "Gradle release build failed with exit code $LASTEXITCODE."
|
|
}
|
|
|
|
$outputMetadataPath = Join-Path $repoRoot 'qsferaApp\build\outputs\apk\original\release\output-metadata.json'
|
|
if (-not (Test-Path $outputMetadataPath)) {
|
|
throw "Release output metadata was not found: $outputMetadataPath"
|
|
}
|
|
|
|
$outputMetadata = Get-Content $outputMetadataPath -Raw | ConvertFrom-Json
|
|
$outputFile = $outputMetadata.elements[0].outputFile
|
|
$releaseApkPath = Join-Path $repoRoot ("qsferaApp\build\outputs\apk\original\release\{0}" -f $outputFile)
|
|
if (-not (Test-Path $releaseApkPath)) {
|
|
throw "Release APK was not found: $releaseApkPath"
|
|
}
|
|
if ($releaseApkPath -like '*-unsigned.apk') {
|
|
throw "Release APK is unsigned: $releaseApkPath"
|
|
}
|
|
|
|
$badging = & $aaptPath dump badging $releaseApkPath
|
|
if ($LASTEXITCODE -ne 0) {
|
|
throw "aapt badging failed with exit code $LASTEXITCODE."
|
|
}
|
|
$badgingText = ($badging | Out-String)
|
|
$packageName = Get-ApkBadgingValue $badgingText "package: name='([^']+)'"
|
|
$versionCode = Get-ApkBadgingValue $badgingText "versionCode='([^']+)'"
|
|
$versionName = Get-ApkBadgingValue $badgingText "versionName='([^']+)'"
|
|
$targetSdk = Get-ApkBadgingValue $badgingText "targetSdkVersion:'([^']+)'"
|
|
$applicationLabel = Get-ApkBadgingValue $badgingText "application-label:'([^']+)'"
|
|
$nativeCodeLine = ($badging | Where-Object { $_ -like 'native-code:*' } | Select-Object -First 1)
|
|
$nativeCodes = if ($nativeCodeLine) {
|
|
[regex]::Matches($nativeCodeLine, "'([^']+)'") | ForEach-Object { $_.Groups[1].Value }
|
|
} else {
|
|
@()
|
|
}
|
|
$detectedPlatform = Resolve-ArgusPlatform $nativeCodes
|
|
$resolvedPlatform = if ([string]::IsNullOrWhiteSpace($Platform)) {
|
|
$detectedPlatform
|
|
} else {
|
|
$Platform.Trim()
|
|
}
|
|
|
|
if (-not $packageName) {
|
|
throw 'Package name could not be extracted from APK.'
|
|
}
|
|
if ($packageName -ne 'eu.qsfera.android') {
|
|
throw "Unexpected package name: $packageName"
|
|
}
|
|
if (-not $versionCode) {
|
|
throw 'Version code could not be extracted from APK.'
|
|
}
|
|
if (-not $versionName) {
|
|
throw 'Version name could not be extracted from APK.'
|
|
}
|
|
if (-not $targetSdk) {
|
|
throw 'Target SDK could not be extracted from APK.'
|
|
}
|
|
|
|
$certOutput = & $apksignerPath verify --print-certs $releaseApkPath
|
|
if ($LASTEXITCODE -ne 0) {
|
|
throw "apksigner verification failed with exit code $LASTEXITCODE."
|
|
}
|
|
$certText = ($certOutput | Out-String)
|
|
$certificateSha256 = Get-ApkBadgingValue $certText "SHA-256 digest: ([0-9a-fA-F]+)"
|
|
if ($certText -match 'CN=Android Debug' -or $certificateSha256 -eq 'b86d745cecb01ca24bda03092fe94ca7801678513b97edd1175bcabfdea4e941') {
|
|
throw 'Release APK is signed with the Android debug certificate.'
|
|
}
|
|
|
|
$resolvedName = if ($Name) { $Name } elseif ($applicationLabel) { $applicationLabel } else { 'QSfera' }
|
|
$releaseVersion = if ($Version) { $Version } else { $versionName }
|
|
$stageDirectory = Join-Path $repoRoot ("build\argus-publication\v{0}" -f $versionCode)
|
|
if (-not (Test-Path $stageDirectory)) {
|
|
New-Item -ItemType Directory -Path $stageDirectory | Out-Null
|
|
}
|
|
|
|
$stagedApkName = "{0}-{1}.apk" -f $Slug, $versionCode
|
|
$stagedApkPath = Join-Path $stageDirectory $stagedApkName
|
|
Copy-Item $releaseApkPath $stagedApkPath -Force
|
|
|
|
$metadata = [ordered]@{
|
|
packagePath = $stagedApkPath
|
|
slug = $Slug
|
|
name = $resolvedName
|
|
summary = $Summary
|
|
description = $Description
|
|
version = $releaseVersion
|
|
channel = $Channel
|
|
platform = $resolvedPlatform
|
|
packageKind = 'apk'
|
|
notes = if ($Notes) { $Notes } else { $null }
|
|
repositoryUrl = if ($RepositoryUrl) { $RepositoryUrl } else { $null }
|
|
homepageUrl = if ($HomepageUrl) { $HomepageUrl } else { $null }
|
|
git = $gitProvenance
|
|
androidPackageName = $packageName
|
|
androidVersionCode = [int]$versionCode
|
|
targetSdkVersion = [int]$targetSdk
|
|
isListed = (-not $Unlisted)
|
|
verifiedFromApk = [ordered]@{
|
|
outputFile = $releaseApkPath
|
|
packageName = $packageName
|
|
versionCode = [int]$versionCode
|
|
versionName = $versionName
|
|
targetSdkVersion = [int]$targetSdk
|
|
nativeCodes = $nativeCodes
|
|
detectedPlatform = $detectedPlatform
|
|
applicationLabel = if ($applicationLabel) { $applicationLabel } else { $null }
|
|
certificateSha256 = $certificateSha256
|
|
verifiedAt = [DateTimeOffset]::UtcNow.ToString('O')
|
|
}
|
|
}
|
|
|
|
$metadataPath = Join-Path $stageDirectory 'argus-publication.json'
|
|
$metadata | ConvertTo-Json -Depth 6 | Set-Content -Path $metadataPath -Encoding UTF8
|
|
|
|
Write-Output 'Prepared QSfera Argus publication files.'
|
|
Write-Output "APK: $stagedApkPath"
|
|
Write-Output "Metadata: $metadataPath"
|