param( [string]$JavaHome, [string]$GradleExe, [string]$SdkDir, [string]$KeystoreInfoPath, [string]$RepositoryUrl, [string]$HomepageUrl, [string]$ReleaseNotes, [switch]$SkipBuild ) Set-StrictMode -Version Latest $ErrorActionPreference = "Stop" function Read-KeyValueFile { param( [Parameter(Mandatory = $true)] [string]$Path ) if (-not (Test-Path -LiteralPath $Path)) { throw "File not found: $Path" } $result = @{} foreach ($line in Get-Content -LiteralPath $Path) { if ([string]::IsNullOrWhiteSpace($line) -or $line.TrimStart().StartsWith("#")) { continue } $parts = $line -split "=", 2 if ($parts.Count -eq 2) { $result[$parts[0].Trim()] = $parts[1].Trim() } } return $result } function Resolve-JavaHome { param([string]$PreferredJavaHome) $candidates = @() if ($PreferredJavaHome) { $candidates += $PreferredJavaHome } if ($env:JAVA_HOME) { $candidates += $env:JAVA_HOME } $candidates += @( "C:\Program Files\Android\Android Studio\jbr", "C:\Program Files\Android\openjdk" ) foreach ($candidate in $candidates | Select-Object -Unique) { if ($candidate -and (Test-Path -LiteralPath (Join-Path $candidate "bin\java.exe"))) { return $candidate } } throw "Java runtime not found. Pass -JavaHome explicitly." } function Resolve-GradleExe { param([string]$PreferredGradleExe) $candidates = @() if ($PreferredGradleExe) { $candidates += $PreferredGradleExe } $wrapperRoot = Join-Path $env:USERPROFILE ".gradle\wrapper\dists" $preferredGradle = Join-Path $wrapperRoot "gradle-8.13-bin" if (Test-Path -LiteralPath $preferredGradle) { $candidates += Get-ChildItem -LiteralPath $preferredGradle -Recurse -Filter "gradle.bat" -ErrorAction SilentlyContinue | Select-Object -ExpandProperty FullName } if (Test-Path -LiteralPath $wrapperRoot) { $candidates += Get-ChildItem -LiteralPath $wrapperRoot -Recurse -Filter "gradle.bat" -ErrorAction SilentlyContinue | Sort-Object FullName -Descending | Select-Object -ExpandProperty FullName } foreach ($candidate in $candidates | Select-Object -Unique) { if ($candidate -and (Test-Path -LiteralPath $candidate)) { return $candidate } } throw "Gradle executable not found. Pass -GradleExe explicitly." } function Resolve-SdkDir { param( [string]$PreferredSdkDir, [string]$ProjectRoot ) $candidates = @() if ($PreferredSdkDir) { $candidates += $PreferredSdkDir } $localPropertiesPath = Join-Path $ProjectRoot "local.properties" if (Test-Path -LiteralPath $localPropertiesPath) { $localProperties = Read-KeyValueFile -Path $localPropertiesPath if ($localProperties.ContainsKey("sdk.dir")) { $candidates += ($localProperties["sdk.dir"] -replace "\\\\", "\") } } if ($env:ANDROID_SDK_ROOT) { $candidates += $env:ANDROID_SDK_ROOT } if ($env:ANDROID_HOME) { $candidates += $env:ANDROID_HOME } foreach ($candidate in $candidates | Select-Object -Unique) { if ($candidate -and (Test-Path -LiteralPath $candidate)) { return $candidate } } throw "Android SDK not found. Pass -SdkDir explicitly." } function Get-LatestBuildToolsDir { param( [Parameter(Mandatory = $true)] [string]$SdkDir ) $buildToolsRoot = Join-Path $SdkDir "build-tools" if (-not (Test-Path -LiteralPath $buildToolsRoot)) { throw "Android build-tools directory not found: $buildToolsRoot" } $latest = Get-ChildItem -LiteralPath $buildToolsRoot -Directory | Sort-Object { [version]$_.Name } -Descending | Select-Object -First 1 if (-not $latest) { throw "No Android build-tools versions found in $buildToolsRoot" } return $latest.FullName } function Get-ArgusTemplate { param( [Parameter(Mandatory = $true)] [string]$ProjectRoot ) $searchRoot = Join-Path $ProjectRoot "artifacts\argus" if (-not (Test-Path -LiteralPath $searchRoot)) { return $null } $templatePath = Get-ChildItem -LiteralPath $searchRoot -Recurse -Filter "argus-release.json" -File | Sort-Object FullName -Descending | Select-Object -First 1 -ExpandProperty FullName if (-not $templatePath) { return $null } return (Get-Content -LiteralPath $templatePath -Raw | ConvertFrom-Json) } function Resolve-PackageFileName { param( [string]$Version, [long]$VersionCode ) return "aletheia-$Version-$VersionCode.apk" } function Resolve-KeystoreInfoPath { param( [string]$PreferredPath, [string]$ProjectRoot ) $candidates = @() if ($PreferredPath) { $candidates += $PreferredPath } $candidates += @( (Join-Path $ProjectRoot "signing\release-keystore-info.txt"), (Join-Path $env:USERPROFILE ".aletheia\signing\release-keystore-info.txt") ) foreach ($candidate in $candidates | Select-Object -Unique) { if ($candidate -and (Test-Path -LiteralPath $candidate)) { return $candidate } } throw "release-keystore-info.txt not found. Pass -KeystoreInfoPath explicitly." } function Resolve-KeystorePath { param( [string]$ConfiguredPath, [string]$KeystoreInfoPath, [string]$ProjectRoot ) $candidates = @() if ($ConfiguredPath) { $candidates += $ConfiguredPath $configuredLeaf = Split-Path -Leaf $ConfiguredPath if ($configuredLeaf) { $candidates += (Join-Path (Split-Path -Parent $KeystoreInfoPath) $configuredLeaf) $candidates += (Join-Path (Join-Path $ProjectRoot "signing") $configuredLeaf) } } foreach ($candidate in $candidates | Select-Object -Unique) { if ($candidate -and (Test-Path -LiteralPath $candidate)) { return $candidate } } throw "Keystore file not found. Checked: $($candidates -join ', ')" } function Resolve-GitOriginUrl { param([string]$ProjectRoot) $gitConfigPath = Join-Path $ProjectRoot ".git\config" if (-not (Test-Path -LiteralPath $gitConfigPath)) { return $null } $inOriginSection = $false foreach ($line in Get-Content -LiteralPath $gitConfigPath) { if ($line -match '^\s*\[remote\s+"origin"\]\s*$') { $inOriginSection = $true continue } if ($line -match '^\s*\[') { $inOriginSection = $false } if ($inOriginSection -and $line -match '^\s*url\s*=\s*(.+?)\s*$') { return $matches[1] } } return $null } $projectRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path $javaHomePath = Resolve-JavaHome -PreferredJavaHome $JavaHome $gradleExePath = Resolve-GradleExe -PreferredGradleExe $GradleExe $sdkDirPath = Resolve-SdkDir -PreferredSdkDir $SdkDir -ProjectRoot $projectRoot $keystoreInfoPath = Resolve-KeystoreInfoPath -PreferredPath $KeystoreInfoPath -ProjectRoot $projectRoot $buildToolsDir = Get-LatestBuildToolsDir -SdkDir $sdkDirPath $gitOriginUrl = Resolve-GitOriginUrl -ProjectRoot $projectRoot $zipalignExe = Join-Path $buildToolsDir "zipalign.exe" $apksignerBat = Join-Path $buildToolsDir "apksigner.bat" if (-not (Test-Path -LiteralPath $zipalignExe)) { throw "zipalign.exe not found: $zipalignExe" } if (-not (Test-Path -LiteralPath $apksignerBat)) { throw "apksigner.bat not found: $apksignerBat" } $env:JAVA_HOME = $javaHomePath $env:ANDROID_SDK_ROOT = $sdkDirPath if (-not $SkipBuild) { & $gradleExePath assembleRelease --stacktrace } $outputMetadataPath = Join-Path $projectRoot "app\build\outputs\apk\release\output-metadata.json" if (-not (Test-Path -LiteralPath $outputMetadataPath)) { throw "Release output metadata not found: $outputMetadataPath" } $outputMetadata = Get-Content -LiteralPath $outputMetadataPath -Raw | ConvertFrom-Json $releaseElement = $outputMetadata.elements | Select-Object -First 1 if (-not $releaseElement) { throw "Release output metadata does not contain any elements." } $versionName = [string]$releaseElement.versionName if ([string]::IsNullOrWhiteSpace($versionName)) { throw "versionName is empty in release output metadata." } $versionCode = [long]$releaseElement.versionCode if ($versionCode -le 0) { throw "versionCode must be positive in release output metadata." } $unsignedApk = Join-Path $projectRoot ("app\build\outputs\apk\release\" + $releaseElement.outputFile) if (-not (Test-Path -LiteralPath $unsignedApk)) { throw "Unsigned release APK not found: $unsignedApk" } $keystoreInfo = Read-KeyValueFile -Path $keystoreInfoPath foreach ($requiredKey in @("KeystorePath", "Alias", "StorePassword", "KeyPassword", "StoreType")) { if (-not $keystoreInfo.ContainsKey($requiredKey)) { throw "Missing '$requiredKey' in $keystoreInfoPath" } } $keystorePath = Resolve-KeystorePath -ConfiguredPath $keystoreInfo["KeystorePath"] -KeystoreInfoPath $keystoreInfoPath -ProjectRoot $projectRoot $template = Get-ArgusTemplate -ProjectRoot $projectRoot $slug = if ($template -and $template.slug) { [string]$template.slug } else { "aletheia-kotlin" } $packageFile = Resolve-PackageFileName -Version $versionName -VersionCode $versionCode $outputDir = Join-Path $projectRoot ("artifacts\argus\$slug\$versionName") $alignedApk = Join-Path $outputDir ([IO.Path]::GetFileNameWithoutExtension($packageFile) + "-aligned.apk") $signedApk = Join-Path $outputDir $packageFile $idsigPath = $signedApk + ".idsig" $summaryPath = Join-Path $outputDir "argus-publish-summary.txt" $manifestPath = Join-Path $outputDir "argus-release.json" New-Item -ItemType Directory -Force -Path $outputDir | Out-Null foreach ($path in @($alignedApk, $signedApk, $idsigPath)) { if (Test-Path -LiteralPath $path) { Remove-Item -LiteralPath $path -Force } } & $zipalignExe -p -f 4 $unsignedApk $alignedApk $storePassFile = Join-Path $env:TEMP "aletheia-store-pass.txt" $keyPassFile = Join-Path $env:TEMP "aletheia-key-pass.txt" try { Set-Content -LiteralPath $storePassFile -Value $keystoreInfo["StorePassword"] -NoNewline Set-Content -LiteralPath $keyPassFile -Value $keystoreInfo["KeyPassword"] -NoNewline & $apksignerBat sign ` --ks $keystorePath ` --ks-type $keystoreInfo["StoreType"] ` --ks-key-alias $keystoreInfo["Alias"] ` --ks-pass ("file:" + $storePassFile) ` --key-pass ("file:" + $keyPassFile) ` --v4-signing-enabled true ` --out $signedApk ` $alignedApk } finally { foreach ($secretPath in @($storePassFile, $keyPassFile)) { if (Test-Path -LiteralPath $secretPath) { Remove-Item -LiteralPath $secretPath -Force } } if (Test-Path -LiteralPath $alignedApk) { Remove-Item -LiteralPath $alignedApk -Force } } $verifyOutputLines = & $apksignerBat verify --verbose --print-certs --v4-signature-file $idsigPath $signedApk $verifyOutput = $verifyOutputLines | Out-String foreach ($requiredVerificationLine in @( "Verified using v2 scheme (APK Signature Scheme v2): true", "Verified using v3 scheme (APK Signature Scheme v3): true", "Verified using v4 scheme (APK Signature Scheme v4): true" )) { if ($verifyOutput -notmatch [regex]::Escape($requiredVerificationLine)) { throw "APK verification did not confirm expected signature state: $requiredVerificationLine" } } $signerInfoLines = $verifyOutputLines | Where-Object { $_ -like "Signer #1 certificate *" } if (-not $signerInfoLines) { throw "Could not extract signer certificate details from apksigner output." } $signerInfo = $signerInfoLines | Out-String $packageSha256 = ((Get-FileHash -LiteralPath $signedApk -Algorithm SHA256).Hash).ToLowerInvariant() $releaseNotesText = if ($PSBoundParameters.ContainsKey("ReleaseNotes")) { $ReleaseNotes } else { "Technical release $versionName for Aletheia Kotlin: project version bumped and Argus package prepared." } $repositoryUrlValue = if ($PSBoundParameters.ContainsKey("RepositoryUrl")) { $RepositoryUrl } elseif ($gitOriginUrl) { $gitOriginUrl } else { "" } $homepageUrlValue = if ($PSBoundParameters.ContainsKey("HomepageUrl")) { $HomepageUrl } else { "" } $manifest = [ordered]@{ slug = $slug name = if ($template -and $template.name) { $template.name } else { "Aletheia" } summary = if ($template -and $template.summary) { $template.summary } else { "Android reader app." } description = if ($template -and $template.description) { $template.description } else { "Android application package prepared for Argus." } repositoryUrl = $repositoryUrlValue homepageUrl = $homepageUrlValue version = $versionName channel = if ($template -and $template.channel) { $template.channel } else { "stable" } platform = if ($template -and $template.platform) { $template.platform } else { "android" } packageKind = if ($template -and $template.packageKind) { $template.packageKind } else { "apk" } releaseNotes = $releaseNotesText # The updater reads the public manifest endpoint. An unlisted app returns 404 there, # so Aletheia releases must stay visible to the public Argus API. publicCatalog = $true packageFile = $packageFile packageSha256 = $packageSha256 signerInfo = ($signerInfo.TrimEnd() -replace "\r?\n", [Environment]::NewLine) } $manifestJson = $manifest | ConvertTo-Json -Depth 4 Set-Content -LiteralPath $manifestPath -Value $manifestJson -Encoding utf8 $summaryLines = @( "Slug: $($manifest.slug)", "Name: $($manifest.name)", "Summary: $($manifest.summary)", "Description: $($manifest.description)", "Repository URL: $($manifest.repositoryUrl)", "Homepage URL: $($manifest.homepageUrl)", "Version: $($manifest.version)", "Channel: $($manifest.channel)", "Platform: $($manifest.platform)", "Package kind: $($manifest.packageKind)", "Release notes: $($manifest.releaseNotes)", "Package file: $signedApk", "Show in public catalog: $($manifest.publicCatalog)", "Package SHA-256: $($manifest.packageSha256)", "", "Signer:", $manifest.signerInfo ) Set-Content -LiteralPath $summaryPath -Value $summaryLines -Encoding utf8 Write-Host "Argus package prepared:" $signedApk Write-Host "SHA-256:" $packageSha256 Write-Host "Manifest:" $manifestPath Write-Host "Summary:" $summaryPath