Align Android Argus publication with SSH contract
This commit is contained in:
@@ -5,12 +5,15 @@
|
||||
- Android update requirement: Android Developers says an update must use the same application ID and the same signing certificate as the installed app, unless it contains a valid proof-of-rotation: <https://developer.android.com/google/play/app-updates>.
|
||||
- QSfera update implementation: `Android/qsferaApp/src/main/java/eu/qsfera/android/presentation/settings/update/AppUpdateManager.kt`.
|
||||
- QSfera Android version and release signing env names: `Android/qsferaApp/build.gradle`.
|
||||
- Argus publication authority: `G:/Repos/Marketplace/ARGUS_PUBLICATION.md`.
|
||||
- Current Argus release and certificate facts verified for this production-readiness pass: `PRODUCTION_READINESS.md`, section `Android Argus Publication Check`.
|
||||
|
||||
## Policy
|
||||
|
||||
- Stable Argus releases for `qsfera-mobile` must keep the same package name and production signing certificate after the first production-signed `1.3.11` / `39` release.
|
||||
- The in-app updater must not bypass certificate mismatch. `AppUpdateManager` validates package name, monotonically newer `versionCode`, Argus `androidVersionCode`, and signer digest before starting Android package installation.
|
||||
- Argus publication is SSH-only and must follow `G:/Repos/Marketplace/ARGUS_PUBLICATION.md`: copy the APK to the Pi, write metadata to `/srv/argus-data/argus.db`, and store package bytes under `/srv/argus-data/Packages`. Do not add or use HTTP `/api/admin/*` publication routes from this repository.
|
||||
- The in-app updater must use the public read-only manifest endpoint, treat HTTP `404` as no update, compare `release.version` with the installed version, download `ARGUS_BASE_URL + release.downloadPath`, verify `release.packageSizeBytes` and `release.sha256`, and only then start Android package installation.
|
||||
- The in-app updater must not bypass certificate mismatch. `AppUpdateManager` validates package name, newer installed APK metadata, optional Argus `androidVersionCode` when present, manifest version, and signer digest before starting Android package installation.
|
||||
- A client installed from the debug-signed `1.3.5` / `33` Argus APK cannot be moved to the current production-signed stable APK (`1.3.13` / `41` as of 2026-06-12) by the in-app updater because the signer digest check intentionally fails.
|
||||
- For debug-signed `1.3.5` installs, the supported migration path is: back up unsynced local files, uninstall the debug-signed build, then install the current stable QSfera APK from Argus.
|
||||
- On certificate mismatch, the Android app must show an explicit reinstall-required dialog instead of a generic update error.
|
||||
@@ -21,5 +24,5 @@
|
||||
2. Prepare Argus publication only from a clean release branch whose `HEAD` matches its upstream; `Android/scripts/prepare-argus-publication.ps1` enforces this by default and records repository root, branch, upstream, commit, and verification time in `argus-publication.json`. When the Android checkout is used from the pushed `QSfera.git` monorepo, run it from `Android/` with `-GitProvenanceRoot ..`.
|
||||
3. Verify the local release signing material and latest Argus APK certificate with `Android/scripts/verify-release-signing-material.ps1 -VerifyPublishedApk`.
|
||||
4. Verify the APK certificate before upload with `apksigner verify --print-certs`.
|
||||
5. Publish to Argus only after certificate, package name, version code, SHA-256, and git provenance checks pass.
|
||||
5. Publish to Argus only through `Android/scripts/publish-release.ps1`, which implements the SSH/SQLite/Data/Packages workflow from `G:/Repos/Marketplace/ARGUS_PUBLICATION.md`; do not publish through HTTP admin APIs.
|
||||
6. Verify the published manifest and download with `scripts/verify-production-state.ps1 -VerifyAndroidDownload` from the repository root.
|
||||
|
||||
+73
-16
@@ -44,7 +44,7 @@ class AppUpdateManager(private val fragment: Fragment) {
|
||||
runCatching {
|
||||
withContext(Dispatchers.IO) { fetchLatestRelease(context) }
|
||||
}.onSuccess { release ->
|
||||
if (release.androidVersionCode <= BuildConfig.VERSION_CODE) {
|
||||
if (release == null || compareVersions(release.version, BuildConfig.VERSION_NAME) <= 0) {
|
||||
showNoUpdatesDialog(context)
|
||||
} else {
|
||||
showUpdateDialog(context, release)
|
||||
@@ -62,9 +62,7 @@ class AppUpdateManager(private val fragment: Fragment) {
|
||||
context.getString(
|
||||
R.string.update_available_message,
|
||||
release.version,
|
||||
release.androidVersionCode,
|
||||
BuildConfig.VERSION_NAME,
|
||||
BuildConfig.VERSION_CODE
|
||||
BuildConfig.VERSION_NAME
|
||||
)
|
||||
)
|
||||
.setNegativeButton(android.R.string.cancel, null)
|
||||
@@ -95,8 +93,13 @@ class AppUpdateManager(private val fragment: Fragment) {
|
||||
fragment.lifecycleScope.launch {
|
||||
runCatching {
|
||||
withContext(Dispatchers.IO) {
|
||||
downloadApk(context, release).also { apk ->
|
||||
val apk = downloadApk(context, release)
|
||||
try {
|
||||
validateDownloadedApk(context, apk, release)
|
||||
apk
|
||||
} catch (error: Throwable) {
|
||||
apk.delete()
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}.onSuccess { apk ->
|
||||
@@ -139,25 +142,45 @@ class AppUpdateManager(private val fragment: Fragment) {
|
||||
.show()
|
||||
}
|
||||
|
||||
private fun fetchLatestRelease(context: Context): ArgusRelease {
|
||||
val json = requestText(ARGUS_MANIFEST_URL)
|
||||
private fun fetchLatestRelease(context: Context): ArgusRelease? {
|
||||
val json = requestText(ARGUS_MANIFEST_URL) ?: return null
|
||||
val release = JSONObject(json).optJSONObject("release")
|
||||
?: error(context.getString(R.string.update_release_not_found))
|
||||
val version = release.getString("version")
|
||||
val channel = release.optString("channel")
|
||||
val platform = release.optString("platform")
|
||||
val packageKind = release.optString("packageKind")
|
||||
val downloadPath = release.getString("downloadPath")
|
||||
val sha256 = release.optString("sha256")
|
||||
val packageSizeBytes = release.optLong("packageSizeBytes", -1L)
|
||||
|
||||
if (channel != ARGUS_CHANNEL ||
|
||||
platform != ARGUS_PLATFORM ||
|
||||
packageKind != ARGUS_PACKAGE_KIND ||
|
||||
sha256.isBlank() ||
|
||||
packageSizeBytes <= 0L
|
||||
) {
|
||||
error(context.getString(R.string.update_metadata_error))
|
||||
}
|
||||
|
||||
return ArgusRelease(
|
||||
version = release.getString("version"),
|
||||
androidVersionCode = release.getInt("androidVersionCode"),
|
||||
downloadUrl = release.getString("downloadPath").toAbsoluteArgusUrl(),
|
||||
sha256 = release.optString("sha256")
|
||||
version = version,
|
||||
androidVersionCode = release.optInt("androidVersionCode").takeIf { it > 0 },
|
||||
downloadUrl = downloadPath.toAbsoluteArgusUrl(),
|
||||
packageSizeBytes = packageSizeBytes,
|
||||
sha256 = sha256.lowercase()
|
||||
)
|
||||
}
|
||||
|
||||
private fun requestText(url: String): String {
|
||||
private fun requestText(url: String): String? {
|
||||
val connection = URL(url).openConnection() as HttpURLConnection
|
||||
return try {
|
||||
connection.connectTimeout = NETWORK_TIMEOUT_MS
|
||||
connection.readTimeout = NETWORK_TIMEOUT_MS
|
||||
connection.requestMethod = "GET"
|
||||
if (connection.responseCode == HttpURLConnection.HTTP_NOT_FOUND) {
|
||||
return null
|
||||
}
|
||||
check(connection.responseCode in 200..299) {
|
||||
"HTTP ${connection.responseCode}"
|
||||
}
|
||||
@@ -170,6 +193,9 @@ class AppUpdateManager(private val fragment: Fragment) {
|
||||
private fun downloadApk(context: Context, release: ArgusRelease): File {
|
||||
val updatesDir = File(context.filesDir, "updates").apply { mkdirs() }
|
||||
val apkFile = File(updatesDir, UPDATE_APK_NAME)
|
||||
if (apkFile.exists()) {
|
||||
apkFile.delete()
|
||||
}
|
||||
val connection = URL(release.downloadUrl).openConnection() as HttpURLConnection
|
||||
|
||||
try {
|
||||
@@ -188,7 +214,13 @@ class AppUpdateManager(private val fragment: Fragment) {
|
||||
connection.disconnect()
|
||||
}
|
||||
|
||||
if (release.sha256.isNotBlank() && apkFile.sha256() != release.sha256.lowercase()) {
|
||||
if (apkFile.length() != release.packageSizeBytes) {
|
||||
apkFile.delete()
|
||||
error(context.getString(R.string.update_metadata_error))
|
||||
}
|
||||
|
||||
if (apkFile.sha256() != release.sha256) {
|
||||
apkFile.delete()
|
||||
error(context.getString(R.string.update_digest_error))
|
||||
}
|
||||
|
||||
@@ -220,7 +252,10 @@ class AppUpdateManager(private val fragment: Fragment) {
|
||||
if (downloadedVersionCode <= BuildConfig.VERSION_CODE) {
|
||||
error(context.getString(R.string.update_version_error))
|
||||
}
|
||||
if (downloadedVersionCode != release.androidVersionCode.toLong()) {
|
||||
if (release.androidVersionCode != null && downloadedVersionCode != release.androidVersionCode.toLong()) {
|
||||
error(context.getString(R.string.update_metadata_error))
|
||||
}
|
||||
if (downloadedPackageInfo.versionName != release.version) {
|
||||
error(context.getString(R.string.update_metadata_error))
|
||||
}
|
||||
|
||||
@@ -326,10 +361,29 @@ class AppUpdateManager(private val fragment: Fragment) {
|
||||
return digest.digest().joinToString("") { "%02x".format(it) }
|
||||
}
|
||||
|
||||
private fun compareVersions(candidate: String, installed: String): Int {
|
||||
val candidateParts = candidate.toVersionParts()
|
||||
val installedParts = installed.toVersionParts()
|
||||
val maxParts = maxOf(candidateParts.size, installedParts.size)
|
||||
for (index in 0 until maxParts) {
|
||||
val candidatePart = candidateParts.getOrElse(index) { 0 }
|
||||
val installedPart = installedParts.getOrElse(index) { 0 }
|
||||
if (candidatePart != installedPart) {
|
||||
return candidatePart.compareTo(installedPart)
|
||||
}
|
||||
}
|
||||
return candidate.compareTo(installed, ignoreCase = true)
|
||||
}
|
||||
|
||||
private fun String.toVersionParts(): List<Int> =
|
||||
split('.', '-', '_')
|
||||
.mapNotNull { part -> part.takeWhile { it.isDigit() }.toIntOrNull() }
|
||||
|
||||
private data class ArgusRelease(
|
||||
val version: String,
|
||||
val androidVersionCode: Int,
|
||||
val androidVersionCode: Int?,
|
||||
val downloadUrl: String,
|
||||
val packageSizeBytes: Long,
|
||||
val sha256: String
|
||||
)
|
||||
|
||||
@@ -337,8 +391,11 @@ class AppUpdateManager(private val fragment: Fragment) {
|
||||
|
||||
companion object {
|
||||
private const val ARGUS_BASE_URL = "https://argus.kusoft.xyz"
|
||||
private const val ARGUS_PLATFORM = "android"
|
||||
private const val ARGUS_CHANNEL = "stable"
|
||||
private const val ARGUS_PACKAGE_KIND = "apk"
|
||||
private const val ARGUS_MANIFEST_URL =
|
||||
"$ARGUS_BASE_URL/api/apps/qsfera-mobile/manifest?platform=android&channel=stable"
|
||||
"$ARGUS_BASE_URL/api/apps/qsfera-mobile/manifest?platform=$ARGUS_PLATFORM&channel=$ARGUS_CHANNEL"
|
||||
private const val UPDATE_APK_NAME = "qsfera-update.apk"
|
||||
private const val APK_MIME_TYPE = "application/vnd.android.package-archive"
|
||||
private const val NETWORK_TIMEOUT_MS = 15_000
|
||||
|
||||
@@ -110,7 +110,7 @@
|
||||
<string name="update_checking">Проверка обновлений…</string>
|
||||
<string name="update_downloading">Скачивание обновления…</string>
|
||||
<string name="update_available_title">Доступно обновление</string>
|
||||
<string name="update_available_message">Доступна версия %1$s (%2$d). Текущая версия: %3$s (%4$d).</string>
|
||||
<string name="update_available_message">Доступна версия %1$s. Текущая версия: %2$s.</string>
|
||||
<string name="update_not_available_title">Обновлений нет</string>
|
||||
<string name="update_not_available_message">Установлена последняя доступная версия.</string>
|
||||
<string name="update_install">Установить</string>
|
||||
|
||||
@@ -112,7 +112,7 @@
|
||||
<string name="update_checking">Checking for updates…</string>
|
||||
<string name="update_downloading">Downloading update…</string>
|
||||
<string name="update_available_title">Update available</string>
|
||||
<string name="update_available_message">Version %1$s (%2$d) is available. Current version: %3$s (%4$d).</string>
|
||||
<string name="update_available_message">Version %1$s is available. Current version: %2$s.</string>
|
||||
<string name="update_not_available_title">No updates</string>
|
||||
<string name="update_not_available_message">You are using the latest available version.</string>
|
||||
<string name="update_install">Install</string>
|
||||
|
||||
@@ -314,6 +314,8 @@ if (-not (Test-Path $stageDirectory)) {
|
||||
$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
|
||||
@@ -328,6 +330,10 @@ $metadata = [ordered]@{
|
||||
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
|
||||
|
||||
+353
-122
@@ -1,141 +1,372 @@
|
||||
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
|
||||
[string]$MetadataPath,
|
||||
[string]$PublicBaseUrl = 'https://argus.kusoft.xyz',
|
||||
[string]$RemoteHost = '192.168.0.185',
|
||||
[string]$RemoteUser = 'sevenhill',
|
||||
[int]$RemotePort = 22,
|
||||
[string]$RemoteTempDirectory = '/tmp',
|
||||
[string]$ArgusData = '/srv/argus-data',
|
||||
[string]$GeneratedScriptPath,
|
||||
[switch]$PrepareOnly,
|
||||
[switch]$RestartArgus
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
Add-Type -AssemblyName System.Net.Http
|
||||
|
||||
function Read-HttpBody([System.Net.Http.HttpResponseMessage]$Response) {
|
||||
if ($null -eq $Response.Content) {
|
||||
return ''
|
||||
function Fail([string]$Message) {
|
||||
Write-Error $Message
|
||||
exit 1
|
||||
}
|
||||
|
||||
function Get-LatestMetadataPath {
|
||||
$repoRoot = Split-Path -Parent $PSScriptRoot
|
||||
$publicationRoot = Join-Path $repoRoot 'build\argus-publication'
|
||||
if (-not (Test-Path -LiteralPath $publicationRoot)) {
|
||||
Fail "Argus publication directory was not found: $publicationRoot"
|
||||
}
|
||||
|
||||
return $Response.Content.ReadAsStringAsync().GetAwaiter().GetResult()
|
||||
}
|
||||
|
||||
function Ensure-Success(
|
||||
[System.Net.Http.HttpResponseMessage]$Response,
|
||||
[string]$Operation
|
||||
) {
|
||||
if ($Response.IsSuccessStatusCode) {
|
||||
return
|
||||
$metadata = Get-ChildItem -LiteralPath $publicationRoot -Recurse -File -Filter 'argus-publication.json' |
|
||||
Sort-Object LastWriteTimeUtc -Descending |
|
||||
Select-Object -First 1
|
||||
if (-not $metadata) {
|
||||
Fail "No argus-publication.json file was found under $publicationRoot"
|
||||
}
|
||||
|
||||
$body = Read-HttpBody $Response
|
||||
throw "$Operation failed with status $([int]$Response.StatusCode) ($($Response.ReasonPhrase)). Body: $body"
|
||||
return $metadata.FullName
|
||||
}
|
||||
|
||||
function New-JsonContent([string]$Json) {
|
||||
return [System.Net.Http.StringContent]::new($Json, [System.Text.Encoding]::UTF8, 'application/json')
|
||||
function Get-RequiredString($Object, [string]$Name) {
|
||||
$property = $Object.PSObject.Properties[$Name]
|
||||
if ($null -eq $property -or [string]::IsNullOrWhiteSpace([string]$property.Value)) {
|
||||
Fail "Publication metadata is missing '$Name'."
|
||||
}
|
||||
|
||||
return [string]$property.Value
|
||||
}
|
||||
|
||||
$resolvedPackagePath = (Resolve-Path -LiteralPath $PackagePath).Path
|
||||
if (-not (Test-Path $resolvedPackagePath)) {
|
||||
throw "Package file was not found: $resolvedPackagePath"
|
||||
function Quote-BashSingle([string]$Value) {
|
||||
if ($null -eq $Value) {
|
||||
$Value = ''
|
||||
}
|
||||
|
||||
return "'" + ($Value -replace "'", "'\''") + "'"
|
||||
}
|
||||
|
||||
$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)
|
||||
function Join-RemotePath([string]$Directory, [string]$Leaf) {
|
||||
return $Directory.TrimEnd('/') + '/' + $Leaf
|
||||
}
|
||||
|
||||
function Invoke-CheckedNative([string]$Program, [string[]]$Arguments) {
|
||||
& $Program @Arguments
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Fail "$Program failed with exit code $LASTEXITCODE."
|
||||
}
|
||||
}
|
||||
|
||||
function Assert-Slug([string]$Slug) {
|
||||
if ($Slug -notmatch '^[a-z0-9][a-z0-9-]{0,99}$') {
|
||||
Fail "Argus slug must contain only lowercase letters, digits, and hyphens, max 100 chars: $Slug"
|
||||
}
|
||||
}
|
||||
|
||||
function Assert-ManifestValue($Manifest, [string]$Name, $Actual, $Expected) {
|
||||
if ([string]$Actual -ne [string]$Expected) {
|
||||
Fail "Published manifest $Name expected '$Expected' but got '$Actual'."
|
||||
}
|
||||
}
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($MetadataPath)) {
|
||||
$MetadataPath = Get-LatestMetadataPath
|
||||
}
|
||||
|
||||
$resolvedMetadataPath = (Resolve-Path -LiteralPath $MetadataPath).Path
|
||||
$metadata = Get-Content -LiteralPath $resolvedMetadataPath -Raw -Encoding UTF8 | ConvertFrom-Json
|
||||
|
||||
$packagePath = (Resolve-Path -LiteralPath (Get-RequiredString $metadata 'packagePath')).Path
|
||||
$slug = Get-RequiredString $metadata 'slug'
|
||||
$name = Get-RequiredString $metadata 'name'
|
||||
$summary = Get-RequiredString $metadata 'summary'
|
||||
$description = Get-RequiredString $metadata 'description'
|
||||
$version = Get-RequiredString $metadata 'version'
|
||||
$channel = (Get-RequiredString $metadata 'channel').ToLowerInvariant()
|
||||
$platform = (Get-RequiredString $metadata 'platform').ToLowerInvariant()
|
||||
$packageKind = (Get-RequiredString $metadata 'packageKind').ToLowerInvariant()
|
||||
$notes = if ($metadata.PSObject.Properties['notes']) { [string]$metadata.notes } else { '' }
|
||||
$repositoryUrl = if ($metadata.PSObject.Properties['repositoryUrl']) { [string]$metadata.repositoryUrl } else { '' }
|
||||
$homepageUrl = if ($metadata.PSObject.Properties['homepageUrl']) { [string]$metadata.homepageUrl } else { '' }
|
||||
$isListed = if ($metadata.PSObject.Properties['isListed'] -and [bool]$metadata.isListed) { '1' } else { '0' }
|
||||
|
||||
Assert-Slug $slug
|
||||
if ($platform -ne 'android') {
|
||||
Fail "QSfera Android publication requires platform=android. Actual platform: $platform"
|
||||
}
|
||||
if ($packageKind -ne 'apk') {
|
||||
Fail "QSfera Android publication requires packageKind=apk. Actual packageKind: $packageKind"
|
||||
}
|
||||
if (-not $packagePath.EndsWith('.apk', [System.StringComparison]::OrdinalIgnoreCase)) {
|
||||
Fail "QSfera Android publication requires an APK package: $packagePath"
|
||||
}
|
||||
|
||||
$scp = Get-Command scp -ErrorAction SilentlyContinue
|
||||
$ssh = Get-Command ssh -ErrorAction SilentlyContinue
|
||||
if (-not $scp -or -not $ssh) {
|
||||
Fail 'Both scp and ssh must be available for SSH-only Argus publication.'
|
||||
}
|
||||
|
||||
$localSize = (Get-Item -LiteralPath $packagePath).Length
|
||||
$localSha256 = (Get-FileHash -Algorithm SHA256 -LiteralPath $packagePath).Hash.ToLowerInvariant()
|
||||
$packageFileName = Split-Path -Leaf $packagePath
|
||||
$remotePackagePath = Join-RemotePath $RemoteTempDirectory $packageFileName
|
||||
$token = [guid]::NewGuid().ToString('N')
|
||||
$remoteScriptPath = Join-RemotePath $RemoteTempDirectory "$slug-publish-$token.sh"
|
||||
$localScriptPath = if ([string]::IsNullOrWhiteSpace($GeneratedScriptPath)) {
|
||||
Join-Path ([System.IO.Path]::GetTempPath()) "$slug-publish-$token.sh"
|
||||
} else {
|
||||
$GeneratedScriptPath
|
||||
}
|
||||
$remote = "$RemoteUser@$RemoteHost"
|
||||
$sshPort = [string]$RemotePort
|
||||
|
||||
$restartCommand = if ($RestartArgus) {
|
||||
'docker restart argus >/dev/null'
|
||||
} else {
|
||||
':'
|
||||
}
|
||||
|
||||
$scriptTemplate = @'
|
||||
set -euo pipefail
|
||||
|
||||
cleanup() {
|
||||
rm -f "$SOURCE_FILE" "$0"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
export ARGUS_DATA=__ARGUS_DATA__
|
||||
export SOURCE_FILE=__SOURCE_FILE__
|
||||
|
||||
export ARGUS_SLUG=__ARGUS_SLUG__
|
||||
export ARGUS_NAME=__ARGUS_NAME__
|
||||
export ARGUS_SUMMARY=__ARGUS_SUMMARY__
|
||||
export ARGUS_DESCRIPTION=__ARGUS_DESCRIPTION__
|
||||
export ARGUS_REPOSITORY_URL=__ARGUS_REPOSITORY_URL__
|
||||
export ARGUS_HOMEPAGE_URL=__ARGUS_HOMEPAGE_URL__
|
||||
export ARGUS_IS_LISTED=__ARGUS_IS_LISTED__
|
||||
|
||||
export ARGUS_VERSION=__ARGUS_VERSION__
|
||||
export ARGUS_CHANNEL=__ARGUS_CHANNEL__
|
||||
export ARGUS_PLATFORM=__ARGUS_PLATFORM__
|
||||
export ARGUS_PACKAGE_KIND=__ARGUS_PACKAGE_KIND__
|
||||
export ARGUS_NOTES=__ARGUS_NOTES__
|
||||
|
||||
python3 - "$SOURCE_FILE" <<'PY'
|
||||
import hashlib
|
||||
import mimetypes
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import sqlite3
|
||||
import sys
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
source = Path(sys.argv[1]).resolve()
|
||||
data_root = Path(os.environ.get("ARGUS_DATA", "/srv/argus-data")).resolve()
|
||||
db_path = data_root / "argus.db"
|
||||
packages_root = data_root / "Packages"
|
||||
|
||||
slug = os.environ["ARGUS_SLUG"].strip()
|
||||
name = os.environ["ARGUS_NAME"].strip()
|
||||
summary = os.environ["ARGUS_SUMMARY"].strip()
|
||||
description = os.environ["ARGUS_DESCRIPTION"].strip()
|
||||
repository_url = os.environ.get("ARGUS_REPOSITORY_URL", "").strip() or None
|
||||
homepage_url = os.environ.get("ARGUS_HOMEPAGE_URL", "").strip() or None
|
||||
is_listed = 1 if os.environ.get("ARGUS_IS_LISTED", "1").strip() != "0" else 0
|
||||
|
||||
version = os.environ["ARGUS_VERSION"].strip()
|
||||
channel = os.environ.get("ARGUS_CHANNEL", "stable").strip().lower()
|
||||
platform = os.environ.get("ARGUS_PLATFORM", "generic").strip().lower()
|
||||
package_kind = os.environ.get("ARGUS_PACKAGE_KIND", "binary").strip().lower()
|
||||
notes = os.environ.get("ARGUS_NOTES", "").strip() or None
|
||||
|
||||
if not re.fullmatch(r"[a-z0-9][a-z0-9-]{0,99}", slug):
|
||||
raise SystemExit("ARGUS_SLUG must contain only lowercase letters, digits, and hyphens, max 100 chars.")
|
||||
if not source.is_file():
|
||||
raise SystemExit(f"SOURCE_FILE does not exist: {source}")
|
||||
if not db_path.is_file():
|
||||
raise SystemExit(f"Argus database does not exist: {db_path}")
|
||||
for key, value in {
|
||||
"ARGUS_NAME": name,
|
||||
"ARGUS_SUMMARY": summary,
|
||||
"ARGUS_DESCRIPTION": description,
|
||||
"ARGUS_VERSION": version,
|
||||
}.items():
|
||||
if not value:
|
||||
raise SystemExit(f"{key} is required.")
|
||||
|
||||
release_id = str(uuid.uuid4())
|
||||
now = datetime.now(timezone.utc).isoformat(timespec="microseconds")
|
||||
safe_version = re.sub(r"[^a-zA-Z0-9._-]+", "-", version).strip("-._") or "release"
|
||||
extension = source.suffix or ".bin"
|
||||
stored_name = f"{datetime.now(timezone.utc):%Y%m%d%H%M%S}-{safe_version}-{release_id.replace('-', '')}{extension}"
|
||||
stored_relative_path = f"{slug}/{stored_name}"
|
||||
target_dir = packages_root / slug
|
||||
target_path = target_dir / stored_name
|
||||
|
||||
target_dir.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(source, target_path)
|
||||
|
||||
size_bytes = target_path.stat().st_size
|
||||
sha256 = hashlib.sha256(target_path.read_bytes()).hexdigest()
|
||||
content_type = mimetypes.guess_type(source.name)[0] or "application/octet-stream"
|
||||
if source.suffix.lower() == ".apk":
|
||||
content_type = "application/vnd.android.package-archive"
|
||||
|
||||
conn = sqlite3.connect(db_path)
|
||||
try:
|
||||
conn.execute("PRAGMA foreign_keys = ON")
|
||||
conn.execute("BEGIN")
|
||||
|
||||
row = conn.execute('SELECT "Id" FROM "Apps" WHERE "Slug" = ?', (slug,)).fetchone()
|
||||
if row is None:
|
||||
app_id = str(uuid.uuid4())
|
||||
conn.execute(
|
||||
'''
|
||||
INSERT INTO "Apps"
|
||||
("Id", "Slug", "Name", "Summary", "Description", "RepositoryUrl", "HomepageUrl", "IsListed", "CreatedAt", "UpdatedAt")
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
''',
|
||||
(app_id, slug, name, summary, description, repository_url, homepage_url, is_listed, now, now),
|
||||
)
|
||||
else:
|
||||
app_id = row[0]
|
||||
conn.execute(
|
||||
'''
|
||||
UPDATE "Apps"
|
||||
SET "Name" = ?,
|
||||
"Summary" = ?,
|
||||
"Description" = ?,
|
||||
"RepositoryUrl" = ?,
|
||||
"HomepageUrl" = ?,
|
||||
"IsListed" = ?,
|
||||
"UpdatedAt" = ?
|
||||
WHERE "Id" = ?
|
||||
''',
|
||||
(name, summary, description, repository_url, homepage_url, is_listed, now, app_id),
|
||||
)
|
||||
|
||||
duplicate = conn.execute(
|
||||
'''
|
||||
SELECT "Id"
|
||||
FROM "Releases"
|
||||
WHERE "CatalogAppId" = ? AND "Version" = ? AND "Channel" = ? AND "Platform" = ?
|
||||
''',
|
||||
(app_id, version, channel, platform),
|
||||
).fetchone()
|
||||
if duplicate is not None:
|
||||
raise RuntimeError(f"Release already exists for {slug} {version} {channel} {platform}.")
|
||||
|
||||
conn.execute(
|
||||
'''
|
||||
INSERT INTO "Releases"
|
||||
("Id", "CatalogAppId", "Version", "Channel", "Platform", "PackageKind",
|
||||
"OriginalFileName", "StoredRelativePath", "ContentType", "PackageSizeBytes",
|
||||
"Sha256", "Notes", "PublishedAt")
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
''',
|
||||
(
|
||||
release_id,
|
||||
app_id,
|
||||
version,
|
||||
channel,
|
||||
platform,
|
||||
package_kind,
|
||||
source.name,
|
||||
stored_relative_path,
|
||||
content_type,
|
||||
size_bytes,
|
||||
sha256,
|
||||
notes,
|
||||
now,
|
||||
),
|
||||
)
|
||||
|
||||
conn.commit()
|
||||
except Exception:
|
||||
conn.rollback()
|
||||
try:
|
||||
target_path.unlink()
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
raise
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
print(f"Published app={slug} version={version} channel={channel} platform={platform}")
|
||||
print(f"StoredRelativePath={stored_relative_path}")
|
||||
print(f"Size={size_bytes}")
|
||||
print(f"Sha256={sha256}")
|
||||
print(f"Manifest=https://argus.kusoft.xyz/api/apps/{slug}/manifest?platform={platform}&channel={channel}")
|
||||
PY
|
||||
|
||||
curl -fsS "http://127.0.0.1:5105/api/apps/$ARGUS_SLUG/manifest?platform=$ARGUS_PLATFORM&channel=$ARGUS_CHANNEL"
|
||||
__RESTART_COMMAND__
|
||||
'@
|
||||
|
||||
$publicationScript = $scriptTemplate.
|
||||
Replace('__ARGUS_DATA__', (Quote-BashSingle $ArgusData)).
|
||||
Replace('__SOURCE_FILE__', (Quote-BashSingle $remotePackagePath)).
|
||||
Replace('__ARGUS_SLUG__', (Quote-BashSingle $slug)).
|
||||
Replace('__ARGUS_NAME__', (Quote-BashSingle $name)).
|
||||
Replace('__ARGUS_SUMMARY__', (Quote-BashSingle $summary)).
|
||||
Replace('__ARGUS_DESCRIPTION__', (Quote-BashSingle $description)).
|
||||
Replace('__ARGUS_REPOSITORY_URL__', (Quote-BashSingle $repositoryUrl)).
|
||||
Replace('__ARGUS_HOMEPAGE_URL__', (Quote-BashSingle $homepageUrl)).
|
||||
Replace('__ARGUS_IS_LISTED__', (Quote-BashSingle $isListed)).
|
||||
Replace('__ARGUS_VERSION__', (Quote-BashSingle $version)).
|
||||
Replace('__ARGUS_CHANNEL__', (Quote-BashSingle $channel)).
|
||||
Replace('__ARGUS_PLATFORM__', (Quote-BashSingle $platform)).
|
||||
Replace('__ARGUS_PACKAGE_KIND__', (Quote-BashSingle $packageKind)).
|
||||
Replace('__ARGUS_NOTES__', (Quote-BashSingle $notes)).
|
||||
Replace('__RESTART_COMMAND__', $restartCommand)
|
||||
|
||||
[System.IO.File]::WriteAllText($localScriptPath, $publicationScript, [System.Text.UTF8Encoding]::new($false))
|
||||
|
||||
if ($PrepareOnly) {
|
||||
Write-Output 'QSfera Android Argus SSH publication script prepared.'
|
||||
Write-Output "Script: $localScriptPath"
|
||||
Write-Output "APK: $packagePath"
|
||||
Write-Output "Remote APK: $remotePackagePath"
|
||||
Write-Output "Size: $localSize"
|
||||
Write-Output "SHA-256: $localSha256"
|
||||
exit 0
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
Invoke-CheckedNative $scp.Source @('-P', $sshPort, $packagePath, "${remote}:$remotePackagePath")
|
||||
Invoke-CheckedNative $scp.Source @('-P', $sshPort, $localScriptPath, "${remote}:$remoteScriptPath")
|
||||
Invoke-CheckedNative $ssh.Source @('-p', $sshPort, $remote, "bash $(Quote-BashSingle $remoteScriptPath)")
|
||||
} finally {
|
||||
$client.Dispose()
|
||||
$handler.Dispose()
|
||||
Remove-Item -LiteralPath $localScriptPath -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
|
||||
$manifestUrl = "$($PublicBaseUrl.TrimEnd('/'))/api/apps/$slug/manifest?platform=$platform&channel=$channel"
|
||||
$manifest = Invoke-RestMethod -Uri $manifestUrl -TimeoutSec 30
|
||||
$release = $manifest.release
|
||||
if ($null -eq $release) {
|
||||
Fail "Published manifest did not return a release: $manifestUrl"
|
||||
}
|
||||
|
||||
Assert-ManifestValue $manifest 'slug' $manifest.slug $slug
|
||||
Assert-ManifestValue $release 'version' $release.version $version
|
||||
Assert-ManifestValue $release 'channel' $release.channel $channel
|
||||
Assert-ManifestValue $release 'platform' $release.platform $platform
|
||||
Assert-ManifestValue $release 'packageKind' $release.packageKind $packageKind
|
||||
Assert-ManifestValue $release 'originalFileName' $release.originalFileName $packageFileName
|
||||
Assert-ManifestValue $release 'packageSizeBytes' $release.packageSizeBytes $localSize
|
||||
Assert-ManifestValue $release 'sha256' $release.sha256 $localSha256
|
||||
|
||||
Write-Output 'QSfera Android Argus SSH publication completed.'
|
||||
Write-Output "Manifest: $manifestUrl"
|
||||
Write-Output "Size: $localSize"
|
||||
Write-Output "SHA-256: $localSha256"
|
||||
|
||||
@@ -24,7 +24,8 @@ Date: 2026-06-08.
|
||||
- Android release builds now fail fast when release signing variables are missing, so unsigned release artifacts cannot be published by accident.
|
||||
- Android version was raised to `1.3.12` with `versionCode` `40`.
|
||||
- Android version was raised to `1.3.13` with `versionCode` `41` for the next Argus publication.
|
||||
- Android now has QSfera-specific Argus publishing scripts: local upload-keystore generation, signed APK staging, APK metadata verification, debug-certificate rejection, and Argus upload.
|
||||
- Android now has QSfera-specific Argus publishing scripts: local upload-keystore generation, signed APK staging, APK metadata verification, debug-certificate rejection, and SSH-only Argus publication through the `/srv/argus-data/argus.db` and `/srv/argus-data/Packages` workflow required by `G:/Repos/Marketplace/ARGUS_PUBLICATION.md`; HTTP `/api/admin/*` publication is not used.
|
||||
- Android Argus update and publication were realigned with `G:/Repos/Marketplace/ARGUS_PUBLICATION.md` on 2026-06-13: `Android/scripts/publish-release.ps1` now generates and runs the SSH/SQLite/Data/Packages server-side publication block, and `AppUpdateManager` uses only the public read-only manifest contract for update discovery.
|
||||
- Android now has `Android/scripts/verify-release-signing-material.ps1` to verify local release signing properties, keystore readability, release certificate SHA-256, and optionally the latest published Argus APK certificate without printing signing secrets.
|
||||
- Android Argus staging now enforces release git provenance by default: the work tree must be clean, the branch must have an upstream, `git fetch` must succeed, and `HEAD` must match `@{u}` before a stable APK is prepared for upload. The generated `argus-publication.json` records the verified repository root, branch, upstream, commit, and timestamp; current monorepo publication can verify the pushed root `QSfera.git` branch by running from `Android/` with `-GitProvenanceRoot ..`.
|
||||
- Android `1.3.9`/`37` was published to Argus as `qsfera-mobile` on 2026-06-10. Argus upload returned release id `53ecc24e-106b-43a5-a39e-ffacd72a43f8`, package size `12226572`, and SHA-256 `cbd2c25328e395ea4da4e0f582feac39dec18d2d47b3e105d344904d8a24f017`.
|
||||
@@ -64,6 +65,7 @@ Date: 2026-06-08.
|
||||
- Added `scripts/verify-production-state.ps1` as a repeatable live production endpoint gate for the public server status, Android Argus manifest/download, and Desktop update feed; the Android gate now pins the stable release id, original APK filename, content type, package size, download path, and SHA-256.
|
||||
- `scripts/verify-production-state.ps1 -VerifyAndroidDownload` passed on 2026-06-10: server `edition=stable`/`productversion=7.0.0`, Android `1.3.12`/`40`, downloaded APK size `12237552`, APK SHA-256 `279d00c6a43072caa0b26269f547fb0dc3268088aae5cd9f4ccfbbb485c35ded`, and valid Desktop no-update feed.
|
||||
- `scripts/verify-production-state.ps1 -VerifyAndroidDownload` passed on 2026-06-12: server `edition=stable`/`productversion=7.0.0`, Android release id `39072ada-7557-48f3-ab42-ad68703d00b0`, Android `1.3.13`/`41`, APK filename `qsfera-mobile-41.apk`, content type `application/vnd.android.package-archive`, downloaded APK size `12261408`, APK SHA-256 `f065e6584e6a09999e72ba0eeeaae903e93fb8980a0426e4b39b5e2366bf6611`, and valid Desktop no-update feed.
|
||||
- `scripts/verify-production-state.ps1 -VerifyAndroidDownload` passed on 2026-06-13 against the current public Argus manifest contract: server `edition=stable`/`productversion=7.0.0`, Android release id `39072ada-7557-48f3-ab42-ad68703d00b0`, Android version `1.3.13`, APK filename `qsfera-mobile-41.apk`, content type `application/vnd.android.package-archive`, downloaded APK size `12261408`, APK SHA-256 `f065e6584e6a09999e72ba0eeeaae903e93fb8980a0426e4b39b5e2366bf6611`, and valid Desktop no-update feed. The live manifest currently omits `androidPackageName` and `androidVersionCode`; the verifier treats both as optional unless strict switches are set.
|
||||
- Added `Server/scripts/verify-rpi-production.sh`, declared a loopback-only Pi metrics port in `Server/docker-compose.rpi5.yml`, and expanded the Raspberry Pi Docker runbook with local/public status checks, container/image checks, Prometheus metrics verification, backup/restore drill steps, and Docker data-root/retention planning.
|
||||
- Added systemd monitoring units for the Pi: `Server/systemd/qsfera-rpi-production-check.service`, `Server/systemd/qsfera-rpi-production-check.timer`, `Server/systemd/qsfera-rpi-production-check-alert@.service`, `Server/systemd/rpi-production-check.env.example`, plus installer `Server/scripts/install-rpi-production-monitoring.sh` and alert helper `Server/scripts/alert-rpi-production-check-failure.sh`.
|
||||
- Deployed the Pi metrics/checker update on 2026-06-09. Before upload, `/mnt/data/qsfera/cloud-server/src/docker-compose.rpi5.yml` was backed up to `/mnt/data/qsfera/backups/docker-compose.rpi5.yml-20260609-181614.bak`; `docker compose -f docker-compose.rpi5.yml up -d` recreated and started `qsfera-cloud-server`; `docker compose ps` reported loopback ports `127.0.0.1:9200->9200/tcp` and `127.0.0.1:9205->9205/tcp`.
|
||||
@@ -84,11 +86,11 @@ Date: 2026-06-08.
|
||||
|
||||
## Android Argus Publication Check
|
||||
|
||||
- Current public Argus manifest for `https://argus.kusoft.xyz/api/apps/qsfera-mobile/manifest?platform=android&channel=stable` reports release id `39072ada-7557-48f3-ab42-ad68703d00b0`, version `1.3.13`, channel `stable`, platform `android`, package kind `apk`, `androidVersionCode` `41`, package size `12261408`, and SHA-256 `f065e6584e6a09999e72ba0eeeaae903e93fb8980a0426e4b39b5e2366bf6611`.
|
||||
- Current public Argus manifest for `https://argus.kusoft.xyz/api/apps/qsfera-mobile/manifest?platform=android&channel=stable` reports release id `39072ada-7557-48f3-ab42-ad68703d00b0`, version `1.3.13`, channel `stable`, platform `android`, package kind `apk`, original file name `qsfera-mobile-41.apk`, content type `application/vnd.android.package-archive`, package size `12261408`, and SHA-256 `f065e6584e6a09999e72ba0eeeaae903e93fb8980a0426e4b39b5e2366bf6611`. It does not currently include legacy Android-specific root `androidPackageName` or release `androidVersionCode` fields.
|
||||
- Independent download verification of `https://argus.kusoft.xyz/api/apps/qsfera-mobile/download/latest?platform=android&channel=stable` produced a `12261408` byte APK with the same SHA-256 `f065e6584e6a09999e72ba0eeeaae903e93fb8980a0426e4b39b5e2366bf6611`.
|
||||
- The previously published `1.3.5`/`33` APK matched Argus SHA-256 `b7d5bdcb49527c00983a15849252fa42189dee4287754803c46cf2f32e2130f3`, but `apksigner` reported `CN=Android Debug` with certificate SHA-256 `b86d745cecb01ca24bda03092fe94ca7801678513b97edd1175bcabfdea4e941`.
|
||||
- The current production-signed `1.3.13`/`41` APK is signed by `CN=QSfera Argus Upload, O=QSfera, C=RU` with certificate SHA-256 `ea5c304c78ee333f6586f54c933d986c1f865da9c4e3595f8fe0df274c1ab877`.
|
||||
- `AppUpdateManager` validates downloaded APK package name, greater `versionCode`, Argus `androidVersionCode`, and signer digest against the installed app signature. Because of that local code path, devices that installed debug-signed `1.3.5` should be treated as needing reinstall or an explicit migration plan before they can move to the current production-signed stable APK.
|
||||
- `AppUpdateManager` follows the public read-only Argus manifest contract from `G:/Repos/Marketplace/ARGUS_PUBLICATION.md`: HTTP `404` is treated as no update, `release.version` is compared with the installed version, downloads use `ARGUS_BASE_URL + release.downloadPath`, `release.packageSizeBytes` and `release.sha256` are verified before install, and the downloaded APK package name/version/signature are checked against the installed app. Because of that local code path, devices that installed debug-signed `1.3.5` should be treated as needing reinstall or an explicit migration plan before they can move to the current production-signed stable APK.
|
||||
- The rollout policy for debug-signed `1.3.5` devices is documented in `Android/docs/argus-rollout-policy.md`: the app does not bypass signer mismatch; users must back up unsynced local files, uninstall the debug-signed build, then install the current stable APK from Argus. The Android app now shows a reinstall-required dialog for this certificate-mismatch path.
|
||||
|
||||
## Remaining P0
|
||||
|
||||
@@ -6,8 +6,10 @@ param(
|
||||
[string]$AndroidManifestUrl = "https://argus.kusoft.xyz/api/apps/qsfera-mobile/manifest?platform=android&channel=stable",
|
||||
[string]$ExpectedAndroidSlug = "qsfera-mobile",
|
||||
[string]$ExpectedAndroidPackageName = "eu.qsfera.android",
|
||||
[switch]$RequireAndroidPackageName,
|
||||
[string]$ExpectedAndroidVersion = "1.3.13",
|
||||
[int]$ExpectedAndroidVersionCode = 41,
|
||||
[switch]$RequireAndroidVersionCode,
|
||||
[string]$ExpectedAndroidChannel = "stable",
|
||||
[string]$ExpectedAndroidPlatform = "android",
|
||||
[string]$ExpectedAndroidPackageKind = "apk",
|
||||
@@ -108,7 +110,14 @@ Write-Host "Checking QSfera Android Argus manifest..."
|
||||
$manifestResponse = Invoke-CheckedWebRequest "Android Argus manifest" $AndroidManifestUrl
|
||||
$manifest = Convert-JsonContent "Android Argus manifest" $manifestResponse.Content
|
||||
Assert-Equal "Android app slug" (Get-RequiredProperty $manifest "slug" "Android manifest") $ExpectedAndroidSlug
|
||||
Assert-Equal "Android package name" (Get-RequiredProperty $manifest "androidPackageName" "Android manifest") $ExpectedAndroidPackageName
|
||||
$androidPackageNameProperty = $manifest.PSObject.Properties["androidPackageName"]
|
||||
if ($null -ne $androidPackageNameProperty) {
|
||||
Assert-Equal "Android package name" $androidPackageNameProperty.Value $ExpectedAndroidPackageName
|
||||
} elseif ($RequireAndroidPackageName) {
|
||||
Fail "Android manifest is missing 'androidPackageName'."
|
||||
} else {
|
||||
Write-Host "OK: Android package name is absent; public Argus contract does not require it"
|
||||
}
|
||||
|
||||
$release = Get-RequiredProperty $manifest "release" "Android manifest"
|
||||
if ($null -eq $release) {
|
||||
@@ -116,7 +125,14 @@ if ($null -eq $release) {
|
||||
}
|
||||
|
||||
Assert-Equal "Android release version" (Get-RequiredProperty $release "version" "Android release") $ExpectedAndroidVersion
|
||||
Assert-Equal "Android versionCode" (Get-RequiredProperty $release "androidVersionCode" "Android release") $ExpectedAndroidVersionCode
|
||||
$androidVersionCodeProperty = $release.PSObject.Properties["androidVersionCode"]
|
||||
if ($null -ne $androidVersionCodeProperty) {
|
||||
Assert-Equal "Android versionCode" $androidVersionCodeProperty.Value $ExpectedAndroidVersionCode
|
||||
} elseif ($RequireAndroidVersionCode) {
|
||||
Fail "Android release is missing 'androidVersionCode'."
|
||||
} else {
|
||||
Write-Host "OK: Android versionCode is absent; public Argus contract does not require it"
|
||||
}
|
||||
Assert-Equal "Android release channel" (Get-RequiredProperty $release "channel" "Android release") $ExpectedAndroidChannel
|
||||
Assert-Equal "Android release platform" (Get-RequiredProperty $release "platform" "Android release") $ExpectedAndroidPlatform
|
||||
Assert-Equal "Android package kind" (Get-RequiredProperty $release "packageKind" "Android release") $ExpectedAndroidPackageKind
|
||||
|
||||
Reference in New Issue
Block a user