Align Android Argus publication with SSH contract

This commit is contained in:
Курнат Андрей
2026-06-13 19:47:25 +03:00
parent b9a0c26b0b
commit 9ad7f0abdb
8 changed files with 462 additions and 147 deletions
+5 -2
View File
@@ -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.
@@ -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
View File
@@ -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"
+5 -3
View File
@@ -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
+18 -2
View File
@@ -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