Clock is a second widget that comes with the launcher: the time in large light figures and the date under it, written onto the wallpaper in the colour of the icon labels. It follows the phone's 12 or 24 hour setting, updates with the minute, and a tap opens the phone's clock app. Like Pictures it is drawn by the launcher itself and keeps to the box of the icons in its cells. What Pictures and Clock share moved into two places: IconFrame works out that box, and FlexeraWidgets is where Launcher3's widget code asks about the launcher's own widgets. The QR code on the About screen now holds the address of the newest APK, so that scanning it starts the download; the release page's address stays under it for typing. For that address to work, the release script uploads each APK a second time under a name that does not change. Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
298 lines
14 KiB
PowerShell
298 lines
14 KiB
PowerShell
<#
|
|
.SYNOPSIS
|
|
Builds a signed release of Flexera Launcher and publishes it to the public release repository.
|
|
|
|
.DESCRIPTION
|
|
One command per release. It
|
|
1. writes the new version to flexera/version.properties and commits it,
|
|
2. builds the signed release APK, compiling the Kotlin code from scratch,
|
|
3. checks that the APK is signed with the release key (not the debug key),
|
|
4. installs the APK on the running Android emulator, starts it and looks for a crash,
|
|
5. creates the release "v<version>" in https://git.mariosem.es/msemes/Flexera-Launcher-Releases,
|
|
6. attaches the APK to it, and
|
|
7. tags the source commit it was built from.
|
|
|
|
Installed copies of the app look at that repository once a day (app.flexera.update.FlexeraUpdater)
|
|
and offer the new version. A release that crashes at start would therefore reach the phone on
|
|
its own, which is why step 4 exists and why nothing is published when it fails.
|
|
|
|
Needs: keystore.properties and the keystore it names in the project root (both are git-ignored;
|
|
without them the script stops), the git login for git.mariosem.es that git already has stored,
|
|
and a running emulator for step 4 (start it with: emulator.exe -avd Flexera_Launcher_A17).
|
|
|
|
.PARAMETER Version
|
|
The new version, as major.minor.patch (for example 0.2.0). Must be higher than the last release.
|
|
|
|
.PARAMETER Notes
|
|
What changed, in plain words. Shown in the app under "What's new" and on the release page.
|
|
|
|
.PARAMETER NotesFile
|
|
A text file with the notes, as an alternative to -Notes.
|
|
|
|
.PARAMETER BuildOnly
|
|
Stop after step 4, without publishing. Try the APK by hand, then run the script again with
|
|
-SkipBuild to publish that same file.
|
|
|
|
.PARAMETER SkipBuild
|
|
Use the APK that is already in build\outputs instead of building again.
|
|
|
|
.PARAMETER NoSmokeTest
|
|
Skip step 4. Only for a machine without an emulator; try the APK on a phone yourself first.
|
|
|
|
.PARAMETER AllowDirty
|
|
Release even though there are uncommitted changes. The APK then matches no commit exactly.
|
|
|
|
.PARAMETER PushSource
|
|
Also push the source branch and the version tag to origin.
|
|
|
|
.EXAMPLE
|
|
.\flexera\release.ps1 -Version 0.2.0 -Notes "The alphabet follows the finger more closely."
|
|
|
|
.EXAMPLE
|
|
.\flexera\release.ps1 -Version 0.2.0 -BuildOnly
|
|
.\flexera\release.ps1 -Version 0.2.0 -SkipBuild -Notes "The alphabet follows the finger more closely."
|
|
#>
|
|
param(
|
|
[Parameter(Mandatory = $true)][string]$Version,
|
|
[string]$Notes = "",
|
|
[string]$NotesFile,
|
|
[switch]$BuildOnly,
|
|
[switch]$SkipBuild,
|
|
[switch]$NoSmokeTest,
|
|
[switch]$AllowDirty,
|
|
[switch]$PushSource
|
|
)
|
|
|
|
$ErrorActionPreference = 'Stop'
|
|
|
|
$Server = 'https://git.mariosem.es'
|
|
$GitHost = 'git.mariosem.es'
|
|
$ReleaseRepository = 'msemes/Flexera-Launcher-Releases'
|
|
$Api = "$Server/api/v1/repos/$ReleaseRepository"
|
|
$VariantDir = 'lawnWithQuickstepGithubRelease'
|
|
$Variant = 'LawnWithQuickstepGithubRelease'
|
|
$AppId = 'com.sema.flexeralauncher'
|
|
$HomeActivity = "$AppId/app.lawnchair.LawnchairLauncher"
|
|
$JavaHome = 'C:\Program Files\Android\Android Studio\jbr'
|
|
$Adb = Join-Path $env:LOCALAPPDATA 'Android\Sdk\platform-tools\adb.exe'
|
|
$SmokeTestSeconds = 25
|
|
|
|
$Root = Split-Path -Parent $PSScriptRoot
|
|
Set-Location $Root
|
|
|
|
function Step([string]$text) { Write-Host "`n== $text" -ForegroundColor Cyan }
|
|
|
|
function Get-ApiClient {
|
|
# The login comes from git's own credential store and stays inside this process.
|
|
$query = Join-Path $env:TEMP "flexera-credential-$PID.txt"
|
|
[IO.File]::WriteAllText($query, "protocol=https`nhost=$GitHost`n`n")
|
|
try { $answer = cmd /c "git credential fill < `"$query`"" } finally { Remove-Item $query -Force }
|
|
$user = ($answer | Where-Object { $_ -like 'username=*' }) -replace '^username=', ''
|
|
$pass = ($answer | Where-Object { $_ -like 'password=*' }) -replace '^password=', ''
|
|
if (-not $user -or -not $pass) { throw "git has no stored login for $GitHost. Run a 'git push' to origin once, then try again." }
|
|
|
|
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
|
|
Add-Type -AssemblyName System.Net.Http
|
|
$client = New-Object System.Net.Http.HttpClient
|
|
$client.Timeout = [TimeSpan]::FromMinutes(15)
|
|
$token = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes("${user}:${pass}"))
|
|
$client.DefaultRequestHeaders.Authorization = New-Object System.Net.Http.Headers.AuthenticationHeaderValue('Basic', $token)
|
|
return $client
|
|
}
|
|
|
|
function Read-Response($response, [string]$what) {
|
|
$text = $response.Content.ReadAsStringAsync().Result
|
|
if (-not $response.IsSuccessStatusCode) {
|
|
throw "$what failed: HTTP $([int]$response.StatusCode) $text"
|
|
}
|
|
if ($text) { return $text | ConvertFrom-Json }
|
|
}
|
|
|
|
# --- 1. Version ----------------------------------------------------------------------------------
|
|
|
|
Step "1/7 Version $Version"
|
|
|
|
if ($Version -notmatch '^\d+\.\d{1,2}\.\d{1,2}$') {
|
|
throw "Version must look like 0.2.0 (major.minor.patch, minor and patch below 100), got '$Version'."
|
|
}
|
|
if ($NotesFile) { $Notes = [IO.File]::ReadAllText((Resolve-Path $NotesFile)) }
|
|
|
|
$client = Get-ApiClient
|
|
|
|
$latestResponse = $client.GetAsync("$Api/releases/latest").Result
|
|
if ($latestResponse.IsSuccessStatusCode) {
|
|
$latest = (Read-Response $latestResponse 'Reading the latest release').tag_name -replace '^v', ''
|
|
if ([version]$Version -le [version]$latest) {
|
|
throw "Version $Version is not higher than the published version $latest."
|
|
}
|
|
Write-Host "Last published version: $latest"
|
|
} elseif ([int]$latestResponse.StatusCode -eq 404) {
|
|
Write-Host "Nothing published yet; this is the first release."
|
|
} else {
|
|
Read-Response $latestResponse 'Reading the latest release' | Out-Null
|
|
}
|
|
|
|
$versionFile = Join-Path $Root 'flexera\version.properties'
|
|
$versionText = [IO.File]::ReadAllText($versionFile)
|
|
$current = ([regex]::Match($versionText, '(?m)^versionName=(.+)$')).Groups[1].Value.Trim()
|
|
|
|
$dirty = git status --porcelain
|
|
if ($dirty -and -not $AllowDirty) {
|
|
throw "There are uncommitted changes. Commit them first, or pass -AllowDirty.`n$($dirty -join "`n")"
|
|
}
|
|
|
|
if ($current -ne $Version) {
|
|
$versionText = [regex]::Replace($versionText, '(?m)^versionName=.+$', "versionName=$Version")
|
|
[IO.File]::WriteAllText($versionFile, $versionText, (New-Object Text.UTF8Encoding($false)))
|
|
git add -- flexera/version.properties
|
|
git commit --quiet -m "Release $Version"
|
|
if ($LASTEXITCODE -ne 0) { throw "Could not commit the version change." }
|
|
Write-Host "Version raised from $current to $Version and committed."
|
|
} else {
|
|
Write-Host "flexera/version.properties already says $Version."
|
|
}
|
|
|
|
# --- 2. Build ------------------------------------------------------------------------------------
|
|
|
|
$apk = Join-Path $Root "build\outputs\apk\lawnWithQuickstepGithub\release\FlexeraLauncher.$Version.github.release.apk"
|
|
|
|
if ($SkipBuild) {
|
|
Step "2/7 Build skipped"
|
|
} else {
|
|
Step "2/7 Building the release APK (about 15 minutes)"
|
|
if (-not (Test-Path (Join-Path $Root 'keystore.properties'))) {
|
|
throw "keystore.properties is missing, so the APK would be signed with the debug key and could not update an installed copy."
|
|
}
|
|
# A release compiles its Kotlin code from scratch. Compiled output reused from an earlier
|
|
# build has produced an APK that crashed at start after a preference class had changed
|
|
# (NoSuchFieldError: $stable), so nothing compiled earlier is trusted here.
|
|
Remove-Item -Recurse -Force (Join-Path $Root "build\intermediates\built_in_kotlinc\$VariantDir") -ErrorAction SilentlyContinue
|
|
Get-ChildItem (Join-Path $Root 'build\kotlin') -Directory -Filter "compile${Variant}Kotlin*" -ErrorAction SilentlyContinue |
|
|
Remove-Item -Recurse -Force
|
|
$env:JAVA_HOME = $JavaHome
|
|
# "2>&1" inside cmd: Gradle prints compiler warnings on its error stream. If this script's
|
|
# output is piped or sent to a file, PowerShell would otherwise take the first such line
|
|
# for an error and stop the build. Only the exit code says whether the build failed.
|
|
cmd /c ".\gradlew.bat assemble$Variant --no-build-cache --console=plain 2>&1"
|
|
if ($LASTEXITCODE -ne 0) { throw "The build failed." }
|
|
}
|
|
if (-not (Test-Path $apk)) { throw "Expected the APK at $apk, but it is not there." }
|
|
|
|
# --- 3. Signature --------------------------------------------------------------------------------
|
|
|
|
Step "3/7 Checking the signature"
|
|
|
|
$apksigner = Get-ChildItem (Join-Path $env:LOCALAPPDATA 'Android\Sdk\build-tools') -Recurse -Filter 'apksigner.bat' |
|
|
Sort-Object FullName | Select-Object -Last 1
|
|
if (-not $apksigner) { throw "apksigner was not found in the Android SDK's build-tools." }
|
|
$env:JAVA_HOME = $JavaHome
|
|
$certificate = & $apksigner.FullName verify --print-certs $apk
|
|
if ($LASTEXITCODE -ne 0) { throw "The APK's signature does not verify." }
|
|
$signer = ($certificate | Where-Object { $_ -match 'certificate DN' }) -join '; '
|
|
if ($signer -match 'Android Debug') { throw "The APK is signed with the debug key ($signer). Check keystore.properties." }
|
|
Write-Host $signer
|
|
Write-Host ("APK: {0} ({1:N1} MB)" -f (Split-Path $apk -Leaf), ((Get-Item $apk).Length / 1MB))
|
|
|
|
# --- 4. Start it once ----------------------------------------------------------------------------
|
|
|
|
if ($NoSmokeTest) {
|
|
Step "4/7 Start-up check skipped"
|
|
Write-Warning "This APK has not been started anywhere. Try it on a phone before publishing."
|
|
} else {
|
|
Step "4/7 Starting the APK on the emulator"
|
|
|
|
$emulator = & $Adb devices | Where-Object { $_ -match '^(emulator-\d+)\s+device$' } |
|
|
ForEach-Object { $Matches[1] } | Select-Object -First 1
|
|
if (-not $emulator) {
|
|
throw "No running emulator found. Start one (emulator.exe -avd Flexera_Launcher_A17) and run this again with -SkipBuild, or pass -NoSmokeTest."
|
|
}
|
|
|
|
$install = & $Adb -s $emulator install -r $apk
|
|
if ($LASTEXITCODE -ne 0 -or -not ($install -match 'Success')) {
|
|
throw "Could not install the APK on $emulator. If an older build signed with another key is installed there, uninstall it first: adb uninstall $AppId"
|
|
}
|
|
# When this app is the emulator's home app, Android restarts it the moment the old copy is
|
|
# gone, and that first start can die inside Android itself while the files are still being
|
|
# swapped (a NullPointerException in ActivityThread.handleBindApplication, with no line of
|
|
# this app in it). That is not the new build crashing: let the swap settle, then start
|
|
# from a clean slate and judge what happens from there.
|
|
Start-Sleep -Seconds 6
|
|
& $Adb -s $emulator shell am force-stop $AppId
|
|
& $Adb -s $emulator logcat -b crash -c
|
|
& $Adb -s $emulator shell am start -n $HomeActivity | Out-Null
|
|
Write-Host "Installed on $emulator and started; watching for $SmokeTestSeconds seconds..."
|
|
Start-Sleep -Seconds $SmokeTestSeconds
|
|
|
|
$crashes = & $Adb -s $emulator logcat -b crash -d
|
|
$ours = $crashes | Where-Object { $_ -match "Process: $([regex]::Escape($AppId))(,|\s|$)" }
|
|
$alive = & $Adb -s $emulator shell pidof $AppId
|
|
if ($ours -or -not $alive) {
|
|
$crashes | Select-Object -First 12 | ForEach-Object { Write-Host $_ }
|
|
throw "The app crashed or is not running after start. Nothing was published."
|
|
}
|
|
Write-Host "No crash, and the app is running."
|
|
}
|
|
|
|
if ($BuildOnly) {
|
|
Write-Host "`nBuilt and checked, not published: $apk" -ForegroundColor Green
|
|
Write-Host "Publish this file with: .\flexera\release.ps1 -Version $Version -SkipBuild -Notes `"...`""
|
|
return
|
|
}
|
|
|
|
# --- 5. Release ----------------------------------------------------------------------------------
|
|
|
|
Step "5/7 Creating release v$Version in $ReleaseRepository"
|
|
|
|
$body = @{ tag_name = "v$Version"; name = "Flexera Launcher $Version"; body = $Notes; draft = $false; prerelease = $false } | ConvertTo-Json
|
|
$content = New-Object System.Net.Http.StringContent($body, [Text.Encoding]::UTF8, 'application/json')
|
|
$release = Read-Response ($client.PostAsync("$Api/releases", $content).Result) 'Creating the release'
|
|
|
|
# --- 6. APK --------------------------------------------------------------------------------------
|
|
|
|
Step "6/7 Uploading the APK"
|
|
|
|
# The APK goes up twice. Once under a name with its version, which is what a person looking at
|
|
# the release sees and what the app's updater downloads. And once under a name that never
|
|
# changes, so that <server>/<repo>/releases/download/latest/FlexeraLauncher.apk always leads to
|
|
# the newest APK: the address in the QR code on the app's About screen (FlexeraUpdater.LATEST_APK_URL).
|
|
# The versioned one goes first, because the updater takes the first .apk it finds.
|
|
$assetNames = @("FlexeraLauncher-$Version.apk", 'FlexeraLauncher.apk')
|
|
$uploaded = @()
|
|
try {
|
|
foreach ($assetName in $assetNames) {
|
|
$stream = [IO.File]::OpenRead($apk)
|
|
try {
|
|
$file = New-Object System.Net.Http.StreamContent($stream)
|
|
$file.Headers.ContentType = [System.Net.Http.Headers.MediaTypeHeaderValue]::Parse('application/vnd.android.package-archive')
|
|
$form = New-Object System.Net.Http.MultipartFormDataContent
|
|
$form.Add($file, 'attachment', $assetName)
|
|
$uploaded += Read-Response ($client.PostAsync("$Api/releases/$($release.id)/assets?name=$assetName", $form).Result) "Uploading $assetName"
|
|
} finally {
|
|
$stream.Dispose()
|
|
}
|
|
}
|
|
} catch {
|
|
# A release without its APK would make every installed copy report a failed check.
|
|
$client.DeleteAsync("$Api/releases/$($release.id)").Result | Out-Null
|
|
$client.DeleteAsync("$Api/tags/v$Version").Result | Out-Null
|
|
throw
|
|
}
|
|
$uploaded | ForEach-Object { Write-Host "Uploaded: $($_.browser_download_url)" }
|
|
|
|
# --- 7. Source tag -------------------------------------------------------------------------------
|
|
|
|
Step "7/7 Tagging the source"
|
|
|
|
git tag -a "v$Version" -m "Flexera Launcher $Version"
|
|
if ($LASTEXITCODE -ne 0) { Write-Warning "Could not create the tag v$Version (does it exist already?)." }
|
|
|
|
if ($PushSource) {
|
|
git push origin HEAD "v$Version"
|
|
if ($LASTEXITCODE -ne 0) { Write-Warning "Pushing the source failed; the release itself is published." }
|
|
} else {
|
|
Write-Host "The source commit and tag are local only. Push them with: git push origin main v$Version"
|
|
}
|
|
|
|
Write-Host "`nPublished Flexera Launcher $Version" -ForegroundColor Green
|
|
Write-Host "$Server/$ReleaseRepository/releases/tag/v$Version"
|