Prepare QSfera production release

This commit is contained in:
Курнат Андрей
2026-06-08 19:57:40 +03:00
parent 2315f25754
commit 43caef7a6a
27 changed files with 1159 additions and 42 deletions
+4
View File
@@ -12,6 +12,10 @@
# Local configuration files (sdk path, etc)
local.properties
# Local Argus release signing secrets
argus-signing.local.properties
signing/
# Mac .DS_Store files
.DS_Store
+28 -2
View File
@@ -14,6 +14,18 @@ def envValue(String primary, String legacy = null) {
return legacy ? System.getenv(legacy) : null
}
def releaseSigningEnv = [
[primary: 'QSFERA_RELEASE_KEYSTORE', legacy: 'OC_RELEASE_KEYSTORE'],
[primary: 'QSFERA_RELEASE_KEYSTORE_PASSWORD', legacy: 'OC_RELEASE_KEYSTORE_PASSWORD'],
[primary: 'QSFERA_RELEASE_KEY_ALIAS', legacy: 'OC_RELEASE_KEY_ALIAS'],
[primary: 'QSFERA_RELEASE_KEY_PASSWORD', legacy: 'OC_RELEASE_KEY_PASSWORD'],
]
def missingReleaseSigningEnvKeys = {
releaseSigningEnv.findAll { !envValue(it.primary, it.legacy) }
.collect { "${it.primary} (${it.legacy} legacy fallback)" }
}
dependencies {
// Data and domain modules
implementation project(':qsferaDomain')
@@ -123,8 +135,8 @@ android {
testInstrumentationRunner "eu.qsfera.android.utils.OCTestAndroidJUnitRunner"
versionCode = 32
versionName = "1.3.4"
versionCode = 34
versionName = "1.3.6"
buildConfigField "String", gitRemote, "\"" + getGitOriginRemote() + "\""
buildConfigField "String", commitSHA1, "\"" + getLatestGitHash() + "\""
@@ -235,6 +247,20 @@ android {
testNamespace "eu.qsfera.android.test"
}
gradle.taskGraph.whenReady { taskGraph ->
def releaseBuildRequested = taskGraph.allTasks.any { task ->
def taskName = task.name.toLowerCase()
taskName.endsWith("release") &&
(taskName.startsWith("assemble") || taskName.startsWith("bundle") || taskName.startsWith("package"))
}
def missingSigningEnvKeys = missingReleaseSigningEnvKeys()
if (releaseBuildRequested && !missingSigningEnvKeys.isEmpty()) {
throw new GradleException(
"Release builds must be signed. Missing env vars: ${missingSigningEnvKeys.join(', ')}"
)
}
}
// Updates output file names of a given variant to format
// [appName].[variant.versionName].[QSFERA_BUILD_NUMBER]-[variant.name].apk.
//
@@ -30,6 +30,7 @@ import eu.qsfera.android.R
import eu.qsfera.android.db.PreferenceManager.PREF__CAMERA_UPLOADS_DEFAULT_PATH
import eu.qsfera.android.domain.automaticuploads.model.FolderBackUpConfiguration
import eu.qsfera.android.domain.automaticuploads.model.FolderBackUpConfiguration.Companion.encodeSourcePaths
import eu.qsfera.android.domain.automaticuploads.model.FolderBackUpConfiguration.Companion.initialSyncTimestamp
import eu.qsfera.android.domain.automaticuploads.model.FolderBackUpConfiguration.Companion.pictureUploadsName
import eu.qsfera.android.domain.automaticuploads.model.UploadBehavior
import eu.qsfera.android.domain.automaticuploads.usecases.GetPictureUploadsConfigurationStreamUseCase
@@ -181,7 +182,7 @@ class SettingsPictureUploadsViewModel(
SavePictureUploadsConfigurationUseCase.Params(
composePictureUploadsConfiguration(
sourcePath = encodeSourcePaths(updatedSourcePaths),
timestamp = System.currentTimeMillis().takeIf { updatedSourcePaths != previousSourcePaths }
timestamp = initialSyncTimestamp.takeIf { updatedSourcePaths != previousSourcePaths }
)
)
)
@@ -220,9 +221,9 @@ class SettingsPictureUploadsViewModel(
behavior = behavior ?: UploadBehavior.COPY,
sourcePath = sourcePath.orEmpty(),
uploadPath = uploadPath ?: PREF__CAMERA_UPLOADS_DEFAULT_PATH,
wifiOnly = wifiOnly ?: false,
wifiOnly = wifiOnly ?: true,
chargingOnly = chargingOnly ?: false,
lastSyncTimestamp = timestamp ?: System.currentTimeMillis(),
lastSyncTimestamp = timestamp ?: initialSyncTimestamp,
name = _pictureUploads.value?.name ?: pictureUploadsName,
spaceId = spaceId,
).also {
@@ -30,6 +30,7 @@ import eu.qsfera.android.R
import eu.qsfera.android.db.PreferenceManager.PREF__CAMERA_UPLOADS_DEFAULT_PATH
import eu.qsfera.android.domain.automaticuploads.model.FolderBackUpConfiguration
import eu.qsfera.android.domain.automaticuploads.model.FolderBackUpConfiguration.Companion.encodeSourcePaths
import eu.qsfera.android.domain.automaticuploads.model.FolderBackUpConfiguration.Companion.initialSyncTimestamp
import eu.qsfera.android.domain.automaticuploads.model.FolderBackUpConfiguration.Companion.videoUploadsName
import eu.qsfera.android.domain.automaticuploads.model.UploadBehavior
import eu.qsfera.android.domain.automaticuploads.usecases.GetVideoUploadsConfigurationStreamUseCase
@@ -181,7 +182,7 @@ class SettingsVideoUploadsViewModel(
SaveVideoUploadsConfigurationUseCase.Params(
composeVideoUploadsConfiguration(
sourcePath = encodeSourcePaths(updatedSourcePaths),
timestamp = System.currentTimeMillis().takeIf { updatedSourcePaths != previousSourcePaths }
timestamp = initialSyncTimestamp.takeIf { updatedSourcePaths != previousSourcePaths }
)
)
)
@@ -221,9 +222,9 @@ class SettingsVideoUploadsViewModel(
behavior = behavior ?: UploadBehavior.COPY,
sourcePath = sourcePath.orEmpty(),
uploadPath = uploadPath ?: PREF__CAMERA_UPLOADS_DEFAULT_PATH,
wifiOnly = wifiOnly ?: false,
wifiOnly = wifiOnly ?: true,
chargingOnly = chargingOnly ?: false,
lastSyncTimestamp = timestamp ?: System.currentTimeMillis(),
lastSyncTimestamp = timestamp ?: initialSyncTimestamp,
name = _videoUploads.value?.name ?: videoUploadsName,
spaceId = spaceId,
).also {
@@ -42,6 +42,9 @@ import eu.qsfera.android.workers.OldLogsCollectorWorker
import eu.qsfera.android.workers.RemoveLocallyFilesWithLastUsageOlderThanGivenTimeWorker
import eu.qsfera.android.workers.UploadFileFromContentUriWorker
import eu.qsfera.android.workers.UploadFileFromFileSystemWorker
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import timber.log.Timber
import java.util.concurrent.TimeUnit
@@ -90,28 +93,30 @@ class WorkManagerProvider(
* concurrent scans or redundant enqueues on rapid foreground/background switches.
*/
fun enqueueImmediateAutomaticUploadsWorker() {
val wm = WorkManager.getInstance(context)
CoroutineScope(Dispatchers.IO).launch {
val wm = WorkManager.getInstance(context)
val periodicRunning = wm.getWorkInfosForUniqueWork(AutomaticUploadsWorker.AUTOMATIC_UPLOADS_WORKER)
.get().any { it.state == WorkInfo.State.RUNNING }
val immediateRunning = wm.getWorkInfosForUniqueWork(AutomaticUploadsWorker.IMMEDIATE_UPLOADS_WORKER)
.get().any { it.state == WorkInfo.State.RUNNING }
val periodicRunning = wm.getWorkInfosForUniqueWork(AutomaticUploadsWorker.AUTOMATIC_UPLOADS_WORKER)
.get().any { it.state == WorkInfo.State.RUNNING }
val immediateRunning = wm.getWorkInfosForUniqueWork(AutomaticUploadsWorker.IMMEDIATE_UPLOADS_WORKER)
.get().any { it.state == WorkInfo.State.RUNNING }
if (periodicRunning || immediateRunning) {
Timber.d("Automatic uploads worker already running, skipping immediate run")
return
if (periodicRunning || immediateRunning) {
Timber.d("Automatic uploads worker already running, skipping immediate run")
return@launch
}
val immediateWorker = OneTimeWorkRequestBuilder<AutomaticUploadsWorker>()
.addTag(AutomaticUploadsWorker.IMMEDIATE_UPLOADS_WORKER)
.setInitialDelay(AutomaticUploadsWorker.WRITE_SAFETY_BUFFER_MS, TimeUnit.MILLISECONDS)
.build()
wm.enqueueUniqueWork(
AutomaticUploadsWorker.IMMEDIATE_UPLOADS_WORKER,
ExistingWorkPolicy.KEEP,
immediateWorker
)
}
val immediateWorker = OneTimeWorkRequestBuilder<AutomaticUploadsWorker>()
.addTag(AutomaticUploadsWorker.IMMEDIATE_UPLOADS_WORKER)
.setInitialDelay(AutomaticUploadsWorker.WRITE_SAFETY_BUFFER_MS, java.util.concurrent.TimeUnit.MILLISECONDS)
.build()
wm.enqueueUniqueWork(
AutomaticUploadsWorker.IMMEDIATE_UPLOADS_WORKER,
ExistingWorkPolicy.KEEP,
immediateWorker
)
}
fun enqueueOldLogsCollectorWorker() {
@@ -128,8 +128,13 @@ class AutomaticUploadsWorker(
private fun checkSourcePathsAreValidUrisOrThrowException(sourcePaths: List<String>) {
sourcePaths.forEach { sourcePath ->
val sourceUri: Uri = sourcePath.toUri()
DocumentFile.fromTreeUri(applicationContext, sourceUri)
val hasPersistedReadPermission = appContext.contentResolver.persistedUriPermissions
.any { uriPermission -> uriPermission.uri == sourceUri && uriPermission.isReadPermission }
val documentTree = DocumentFile.fromTreeUri(applicationContext, sourceUri)
?: throw IllegalArgumentException("Source path is not a valid tree URI: $sourcePath")
if (!hasPersistedReadPermission || !documentTree.canRead()) {
throw IllegalArgumentException("Source path is not readable: $sourcePath")
}
}
}
@@ -38,6 +38,7 @@ data class FolderBackUpConfiguration(
companion object {
const val pictureUploadsName = "Picture uploads"
const val videoUploadsName = "Video uploads"
const val initialSyncTimestamp = 0L
fun parseSourcePaths(sourcePath: String): List<String> =
sourcePath
@@ -0,0 +1,96 @@
param(
[string]$KeystorePath = 'signing/argus-upload.jks',
[string]$PropertiesPath = 'argus-signing.local.properties',
[string]$KeyAlias = 'argus-upload',
[switch]$Force
)
$ErrorActionPreference = 'Stop'
function New-Secret {
$value = ((1..3 | ForEach-Object { [Guid]::NewGuid().ToString('N') }) -join '')
return $value.Substring(0, 32)
}
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) {
$keytool = Join-Path $candidate 'bin\keytool.exe'
if (Test-Path $keytool) {
return $candidate
}
}
throw 'keytool.exe was not found. Set JAVA_HOME to a JDK path.'
}
$repoRoot = Split-Path -Parent $PSScriptRoot
$resolvedKeystorePath = if ([System.IO.Path]::IsPathRooted($KeystorePath)) {
$KeystorePath
} else {
Join-Path $repoRoot $KeystorePath
}
$resolvedPropertiesPath = if ([System.IO.Path]::IsPathRooted($PropertiesPath)) {
$PropertiesPath
} else {
Join-Path $repoRoot $PropertiesPath
}
if ((Test-Path $resolvedKeystorePath) -and (Test-Path $resolvedPropertiesPath) -and -not $Force) {
Write-Output 'Keystore and properties already exist.'
Write-Output "Keystore: $resolvedKeystorePath"
Write-Output "Properties: $resolvedPropertiesPath"
exit 0
}
if (((Test-Path $resolvedKeystorePath) -xor (Test-Path $resolvedPropertiesPath)) -and -not $Force) {
throw 'Keystore and properties are in a partial state. Use -Force to recreate the pair.'
}
$keystoreDirectory = Split-Path -Parent $resolvedKeystorePath
if (-not (Test-Path $keystoreDirectory)) {
New-Item -ItemType Directory -Path $keystoreDirectory | Out-Null
}
$storePassword = New-Secret
$keyPassword = $storePassword
$javaHome = Resolve-JavaHome
$keytoolPath = Join-Path $javaHome 'bin\keytool.exe'
if (Test-Path $resolvedKeystorePath) {
Remove-Item $resolvedKeystorePath -Force
}
& $keytoolPath `
-genkeypair `
-keystore $resolvedKeystorePath `
-storetype PKCS12 `
-storepass $storePassword `
-keypass $keyPassword `
-alias $KeyAlias `
-keyalg RSA `
-keysize 4096 `
-validity 10000 `
-dname 'CN=QSfera Argus Upload, O=QSfera, C=RU' | Out-Null
if ($LASTEXITCODE -ne 0) {
throw "keytool failed with exit code $LASTEXITCODE."
}
$propertiesContent = @(
"QSFERA_RELEASE_KEYSTORE=$resolvedKeystorePath"
"QSFERA_RELEASE_KEYSTORE_PASSWORD=$storePassword"
"QSFERA_RELEASE_KEY_ALIAS=$KeyAlias"
"QSFERA_RELEASE_KEY_PASSWORD=$keyPassword"
) -join [Environment]::NewLine
[System.IO.File]::WriteAllText($resolvedPropertiesPath, $propertiesContent, [System.Text.Encoding]::ASCII)
Write-Output 'Created QSfera Argus upload keystore.'
Write-Output "Keystore: $resolvedKeystorePath"
Write-Output "Properties: $resolvedPropertiesPath"
@@ -0,0 +1,277 @@
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',
[switch]$Unlisted,
[switch]$SkipSigningInitialization
)
$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
}
$repoRoot = Split-Path -Parent $PSScriptRoot
Set-Location $repoRoot
$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 }
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"
+141
View File
@@ -0,0 +1,141 @@
param(
[string]$BaseUrl = 'https://argus.kusoft.xyz',
[Parameter(Mandatory = $true)]
[string]$Username,
[Parameter(Mandatory = $true)]
[string]$Password,
[Parameter(Mandatory = $true)]
[string]$Slug,
[Parameter(Mandatory = $true)]
[string]$Name,
[Parameter(Mandatory = $true)]
[string]$Summary,
[Parameter(Mandatory = $true)]
[string]$Description,
[Parameter(Mandatory = $true)]
[string]$AndroidPackageName,
[Parameter(Mandatory = $true)]
[string]$Version,
[Parameter(Mandatory = $true)]
[ValidateRange(1, [int]::MaxValue)]
[int]$AndroidVersionCode,
[Parameter(Mandatory = $true)]
[string]$PackagePath,
[string]$Channel = 'stable',
[string]$Platform = 'android',
[string]$PackageKind = 'apk',
[string]$Notes,
[string]$RepositoryUrl,
[string]$HomepageUrl = 'https://qsfera.kusoft.xyz',
[switch]$Unlisted
)
$ErrorActionPreference = 'Stop'
Add-Type -AssemblyName System.Net.Http
function Read-HttpBody([System.Net.Http.HttpResponseMessage]$Response) {
if ($null -eq $Response.Content) {
return ''
}
return $Response.Content.ReadAsStringAsync().GetAwaiter().GetResult()
}
function Ensure-Success(
[System.Net.Http.HttpResponseMessage]$Response,
[string]$Operation
) {
if ($Response.IsSuccessStatusCode) {
return
}
$body = Read-HttpBody $Response
throw "$Operation failed with status $([int]$Response.StatusCode) ($($Response.ReasonPhrase)). Body: $body"
}
function New-JsonContent([string]$Json) {
return [System.Net.Http.StringContent]::new($Json, [System.Text.Encoding]::UTF8, 'application/json')
}
$resolvedPackagePath = (Resolve-Path -LiteralPath $PackagePath).Path
if (-not (Test-Path $resolvedPackagePath)) {
throw "Package file was not found: $resolvedPackagePath"
}
$baseUri = $BaseUrl.TrimEnd('/')
$cookieContainer = [System.Net.CookieContainer]::new()
$handler = [System.Net.Http.HttpClientHandler]::new()
$handler.CookieContainer = $cookieContainer
$handler.UseCookies = $true
$client = [System.Net.Http.HttpClient]::new($handler)
$client.Timeout = [TimeSpan]::FromMinutes(15)
try {
$loginPayload = @{
username = $Username
password = $Password
} | ConvertTo-Json -Compress
$loginResponse = $client.PostAsync(
"$baseUri/api/admin/session/login",
(New-JsonContent $loginPayload)
).GetAwaiter().GetResult()
Ensure-Success $loginResponse 'Argus login'
$appPayload = @{
name = $Name
summary = $Summary
description = $Description
androidPackageName = $AndroidPackageName
repositoryUrl = if ([string]::IsNullOrWhiteSpace($RepositoryUrl)) { $null } else { $RepositoryUrl }
homepageUrl = if ([string]::IsNullOrWhiteSpace($HomepageUrl)) { $null } else { $HomepageUrl }
isListed = (-not $Unlisted)
} | ConvertTo-Json -Compress
$appResponse = $client.PutAsync(
"$baseUri/api/admin/apps/$Slug",
(New-JsonContent $appPayload)
).GetAwaiter().GetResult()
Ensure-Success $appResponse 'Argus app metadata update'
$form = [System.Net.Http.MultipartFormDataContent]::new()
$fields = [ordered]@{
Version = $Version
Channel = $Channel
Platform = $Platform
PackageKind = $PackageKind
AndroidVersionCode = [string]$AndroidVersionCode
}
foreach ($entry in $fields.GetEnumerator()) {
$form.Add([System.Net.Http.StringContent]::new($entry.Value, [System.Text.Encoding]::UTF8), $entry.Key)
}
if (-not [string]::IsNullOrWhiteSpace($Notes)) {
$form.Add([System.Net.Http.StringContent]::new($Notes, [System.Text.Encoding]::UTF8), 'Notes')
}
$fileStream = [System.IO.File]::OpenRead($resolvedPackagePath)
try {
$fileContent = [System.Net.Http.StreamContent]::new($fileStream)
$fileContent.Headers.ContentType = [System.Net.Http.Headers.MediaTypeHeaderValue]::Parse('application/vnd.android.package-archive')
$form.Add($fileContent, 'Package', [System.IO.Path]::GetFileName($resolvedPackagePath))
$releaseResponse = $client.PostAsync(
"$baseUri/api/admin/apps/$Slug/releases",
$form
).GetAwaiter().GetResult()
Ensure-Success $releaseResponse 'Argus release upload'
$releaseBody = Read-HttpBody $releaseResponse
if (-not [string]::IsNullOrWhiteSpace($releaseBody)) {
Write-Output $releaseBody
}
} finally {
$fileStream.Dispose()
$form.Dispose()
}
} finally {
$client.Dispose()
$handler.Dispose()
}