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 $stagedApkSize = (Get-Item -LiteralPath $stagedApkPath).Length $stagedApkSha256 = (Get-FileHash -Algorithm SHA256 -LiteralPath $stagedApkPath).Hash.ToLowerInvariant() $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 } packageSizeBytes = $stagedApkSize sha256 = $stagedApkSha256 publicationMethod = 'ssh-sqlite-data-packages' publicationInstruction = 'G:/Repos/Marketplace/ARGUS_PUBLICATION.md' 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"