diff --git a/Keeper/README.md b/Keeper/README.md index 86ffed87..af01de02 100644 --- a/Keeper/README.md +++ b/Keeper/README.md @@ -37,17 +37,11 @@ go run .\services\keepernotes\cmd\keepernotes -addr :8098 -data .\keeper-notes.j ## Publish to Argus -The Argus publisher script uses the admin endpoints exposed by `https://argus.kusoft.xyz/site.js`: +Argus publication follows `G:\Repos\Marketplace\ARGUS_PUBLICATION.md`: copy the APK over SSH, then insert release metadata into `/srv/argus-data/argus.db` on the Argus host. The app must only use the public read-only manifest API for updates. -* `POST /api/admin/session/login` -* `PUT /api/admin/apps/{slug}` -* `POST /api/admin/apps/{slug}/releases` - -Set credentials in the current shell, then run: +Build the APK, then run: ```powershell -$env:ARGUS_USERNAME='admin' -$env:ARGUS_PASSWORD='' .\scripts\publish-argus.ps1 ``` @@ -57,6 +51,6 @@ Default publish metadata: * package: `eu.qsfera.keeper` * name: `QKeep` * Android label: `КуЗаметки` -* version: `0.1.21` -* Android version code: `22` +* version: `0.1.22` +* Android version code: `23` * channel: `stable` diff --git a/Keeper/keeperApp/build.gradle b/Keeper/keeperApp/build.gradle index de57180f..76475389 100644 --- a/Keeper/keeperApp/build.gradle +++ b/Keeper/keeperApp/build.gradle @@ -23,8 +23,8 @@ android { applicationId "eu.qsfera.keeper" minSdkVersion sdkMinVersion targetSdkVersion sdkTargetVersion - versionCode 22 - versionName "0.1.21" + versionCode 23 + versionName "0.1.22" def apiBaseUrl = project.findProperty("keeperApiBaseUrl") ?: "https://qsfera.kusoft.xyz/keeper-notes" def cloudBaseUrl = project.findProperty("qsferaCloudBaseUrl") ?: "https://qsfera.kusoft.xyz" diff --git a/Keeper/keeperApp/src/main/java/eu/qsfera/keeper/MainActivity.kt b/Keeper/keeperApp/src/main/java/eu/qsfera/keeper/MainActivity.kt index e509c90d..13a09e0c 100644 --- a/Keeper/keeperApp/src/main/java/eu/qsfera/keeper/MainActivity.kt +++ b/Keeper/keeperApp/src/main/java/eu/qsfera/keeper/MainActivity.kt @@ -161,6 +161,7 @@ enum class BodyTextStyle { class MainActivity : AppCompatActivity() { private lateinit var authPreferences: AuthPreferences private lateinit var keepSettings: KeepSettings + private lateinit var versionCleanup: VersionCleanup private lateinit var authClient: QsferaAuthClient private lateinit var repository: NotesRepository private val updateClient = AppUpdateClient(ARGUS_MANIFEST_URL) @@ -183,10 +184,11 @@ class MainActivity : AppCompatActivity() { super.onCreate(savedInstanceState) authPreferences = AuthPreferences(applicationContext) keepSettings = KeepSettings(applicationContext) + versionCleanup = VersionCleanup(applicationContext) authClient = QsferaAuthClient(BuildConfig.QSFERA_CLOUD_BASE_URL) repository = NotesRepository(applicationContext, authPreferences, authClient) configureSystemBars() - VersionCleanup(applicationContext).cleanupAfterUpdate(BuildConfig.VERSION_CODE, BuildConfig.VERSION_NAME) + versionCleanup.cleanupAfterUpdate(BuildConfig.VERSION_CODE, BuildConfig.VERSION_NAME) ReminderScheduler(applicationContext).ensureNotificationChannel() ensureNotificationPermission() @@ -799,12 +801,15 @@ class MainActivity : AppCompatActivity() { checkButton.isEnabled = true updateStatus.text = result.fold( onSuccess = { latest -> - if (latest.androidVersionCode > BuildConfig.VERSION_CODE) { + val installedVersion = versionCleanup.installedVersionName(BuildConfig.VERSION_NAME) + if (latest == null) { + getString(R.string.no_update_available) + } else if (AppVersionComparator.isRemoteNewer(installedVersion, latest.version)) { availableUpdate = latest installButton.visibility = View.VISIBLE - getString(R.string.update_available, latest.version, latest.androidVersionCode) + getString(R.string.update_available, latest.version) } else { - getString(R.string.up_to_date, BuildConfig.VERSION_NAME, BuildConfig.VERSION_CODE) + getString(R.string.up_to_date, installedVersion) } }, onFailure = { @@ -827,6 +832,7 @@ class MainActivity : AppCompatActivity() { installButton.isEnabled = true result .onSuccess { apk -> + versionCleanup.markUpdateStaged(update.version) updateStatus.text = getString(R.string.opening_installer) installApk(apk) } @@ -3099,13 +3105,13 @@ data class PendingOAuth( data class AppUpdateInfo( val version: String, - val androidVersionCode: Int, val downloadUrl: String, - val sha256: String?, + val sha256: String, + val packageSizeBytes: Long?, ) class AppUpdateClient(private val manifestUrl: String) { - fun latest(): AppUpdateInfo { + fun latest(): AppUpdateInfo? { val connection = (URL(manifestUrl).openConnection() as HttpURLConnection).apply { requestMethod = "GET" connectTimeout = 8000 @@ -3116,16 +3122,22 @@ class AppUpdateClient(private val manifestUrl: String) { return try { val response = if (connection.responseCode in 200..299) { connection.inputStream.bufferedReader(Charsets.UTF_8).use { it.readText() } + } else if (connection.responseCode == HttpURLConnection.HTTP_NOT_FOUND) { + return null } else { val errorText = connection.errorStream?.bufferedReader(Charsets.UTF_8)?.use { it.readText() }.orEmpty() throw IOException("HTTP ${connection.responseCode}: $errorText") } val release = JSONObject(response).getJSONObject("release") + val sha256 = release.optString("sha256").trim() + if (!sha256.matches(Regex("[0-9a-fA-F]{64}"))) { + throw IOException("Argus release sha256 is missing or invalid") + } AppUpdateInfo( version = release.getString("version"), - androidVersionCode = release.getInt("androidVersionCode"), downloadUrl = release.getString("downloadPath").toAbsoluteArgusUrl(), - sha256 = release.optString("sha256").takeIf { it.isNotBlank() } + sha256 = sha256, + packageSizeBytes = release.optLong("packageSizeBytes", -1L).takeIf { it >= 0L } ) } finally { connection.disconnect() @@ -3139,7 +3151,10 @@ class AppUpdateClient(private val manifestUrl: String) { updateDir.mkdirs() } val apk = File(updateDir, "keeper-android-${update.version}.apk") + val tempApk = File(updateDir, "keeper-android-${update.version}.apk.download") + tempApk.delete() val digest = MessageDigest.getInstance("SHA-256") + var downloadedBytes = 0L val connection = (URL(update.downloadUrl).openConnection() as HttpURLConnection).apply { requestMethod = "GET" connectTimeout = 8000 @@ -3153,25 +3168,38 @@ class AppUpdateClient(private val manifestUrl: String) { throw IOException("HTTP ${connection.responseCode}: $errorText") } connection.inputStream.use { input -> - apk.outputStream().use { output -> + tempApk.outputStream().use { output -> val buffer = ByteArray(DEFAULT_BUFFER_SIZE) while (true) { val read = input.read(buffer) if (read < 0) break digest.update(buffer, 0, read) output.write(buffer, 0, read) + downloadedBytes += read } } } + } catch (error: Exception) { + tempApk.delete() + throw error } finally { connection.disconnect() } val actualSha256 = digest.digest().toHexString() - val expectedSha256 = update.sha256 - if (expectedSha256 != null && !expectedSha256.equals(actualSha256, ignoreCase = true)) { - apk.delete() + if (!update.sha256.equals(actualSha256, ignoreCase = true)) { + tempApk.delete() throw IOException("Downloaded APK checksum mismatch") } + val expectedSize = update.packageSizeBytes + if (expectedSize != null && expectedSize != downloadedBytes) { + tempApk.delete() + throw IOException("Downloaded APK size mismatch") + } + apk.delete() + if (!tempApk.renameTo(apk)) { + tempApk.copyTo(apk, overwrite = true) + tempApk.delete() + } return apk } @@ -3184,6 +3212,32 @@ class AppUpdateClient(private val manifestUrl: String) { } } +object AppVersionComparator { + private val semanticVersionPattern = Regex("""^(\d+)\.(\d+)\.(\d+)(?:[-+].*)?$""") + + fun isRemoteNewer(installedVersion: String, remoteVersion: String): Boolean { + val installedSemanticVersion = installedVersion.toSemanticVersionParts() + val remoteSemanticVersion = remoteVersion.toSemanticVersionParts() + if (installedSemanticVersion != null && remoteSemanticVersion != null) { + installedSemanticVersion.indices.forEach { index -> + val comparison = remoteSemanticVersion[index].compareTo(installedSemanticVersion[index]) + if (comparison != 0) return comparison > 0 + } + return false + } + return remoteVersion.compareTo(installedVersion, ignoreCase = true) > 0 + } + + private fun String.toSemanticVersionParts(): List? { + val match = semanticVersionPattern.matchEntire(trim()) ?: return null + return listOf( + match.groupValues[1].toInt(), + match.groupValues[2].toInt(), + match.groupValues[3].toInt() + ) + } +} + class VersionCleanup(private val context: Context) { private val preferences = context.getSharedPreferences("keeper_runtime", Context.MODE_PRIVATE) @@ -3193,10 +3247,26 @@ class VersionCleanup(private val context: Context) { cleanupOldPackageFiles(currentVersionName) preferences.edit() .putInt(KEY_LAST_VERSION_CODE, currentVersionCode) + .putString(KEY_INSTALLED_VERSION_NAME, currentVersionName) + .apply() + } else if (preferences.getString(KEY_INSTALLED_VERSION_NAME, null) != currentVersionName) { + preferences.edit() + .putString(KEY_INSTALLED_VERSION_NAME, currentVersionName) .apply() } } + fun installedVersionName(defaultVersionName: String): String { + return preferences.getString(KEY_INSTALLED_VERSION_NAME, null) ?: defaultVersionName + } + + fun markUpdateStaged(versionName: String) { + preferences.edit() + .putString(KEY_STAGED_VERSION_NAME, versionName) + .putLong(KEY_STAGED_AT, System.currentTimeMillis()) + .apply() + } + fun cleanupOldPackageFiles(currentVersionName: String) { packageFileRoots().forEach { root -> if (!root.exists()) return@forEach @@ -3229,6 +3299,9 @@ class VersionCleanup(private val context: Context) { private companion object { const val KEY_LAST_VERSION_CODE = "last_version_code" + const val KEY_INSTALLED_VERSION_NAME = "installed_version_name" + const val KEY_STAGED_VERSION_NAME = "staged_version_name" + const val KEY_STAGED_AT = "staged_at" } } diff --git a/Keeper/keeperApp/src/main/res/values/strings.xml b/Keeper/keeperApp/src/main/res/values/strings.xml index a6aad18e..4b72ffbc 100644 --- a/Keeper/keeperApp/src/main/res/values/strings.xml +++ b/Keeper/keeperApp/src/main/res/values/strings.xml @@ -127,8 +127,9 @@ Проверяю обновления… Скачиваю обновление… Открываю установщик Android… - Установлена актуальная версия %1$s (%2$d). - Доступна версия %1$s (%2$d). + Установлена актуальная версия %1$s. + В Argus нет релиза для этого приложения. + Доступна версия %1$s. Не удалось проверить обновления. Не удалось скачать обновление. Разрешите установку из этого источника и повторите установку обновления. diff --git a/Keeper/scripts/publish-argus.ps1 b/Keeper/scripts/publish-argus.ps1 index 8d6f03f7..261128e4 100644 --- a/Keeper/scripts/publish-argus.ps1 +++ b/Keeper/scripts/publish-argus.ps1 @@ -1,99 +1,231 @@ param( + [string]$SshHost = $(if ($env:ARGUS_SSH_HOST) { $env:ARGUS_SSH_HOST } else { "192.168.0.185" }), + [string]$SshUser = $(if ($env:ARGUS_SSH_USER) { $env:ARGUS_SSH_USER } else { "sevenhill" }), [string]$ArgusBaseUrl = $(if ($env:ARGUS_BASE_URL) { $env:ARGUS_BASE_URL } else { "https://argus.kusoft.xyz" }), - [string]$Username = $env:ARGUS_USERNAME, - [string]$Password = $env:ARGUS_PASSWORD, + [string]$ArgusData = $(if ($env:ARGUS_DATA) { $env:ARGUS_DATA } else { "/srv/argus-data" }), [string]$ApkPath = "keeperApp\build\outputs\apk\debug\keeperApp-debug.apk", [string]$Slug = "keeper-android", [string]$Name = "QKeep", [string]$Summary = "Cloud notes app for QSfera with local storage and sync.", - [string]$Description = "QKeep stores notes locally on Android, uses QSfera browser authorization, syncs notes with the Keeper REST service, and installs Argus updates from inside the app.", - [string]$AndroidPackageName = "eu.qsfera.keeper", - [string]$Version = "0.1.21", - [int]$AndroidVersionCode = 22, + [string]$Description = "QKeep stores notes locally on Android, uses QSfera browser authorization, syncs notes with the Keeper REST service, and installs SHA-256 verified Argus updates.", + [string]$RepositoryUrl = "", + [string]$HomepageUrl = "", + [string]$Version = "0.1.22", [string]$Channel = "stable", [string]$Platform = "android", [string]$PackageKind = "apk", - [string]$Notes = "Adds Keep-like list behavior settings for new and checked checklist items." + [string]$Notes = "Aligns the Android updater and publication flow with ARGUS_PUBLICATION.md." ) $ErrorActionPreference = "Stop" -if ([string]::IsNullOrWhiteSpace($Username) -or [string]::IsNullOrWhiteSpace($Password)) { - throw "Set ARGUS_USERNAME and ARGUS_PASSWORD before publishing." +function ConvertTo-Base64Utf8([string]$Value) { + return [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($Value)) +} + +function Assert-LastExitCode([string]$Action) { + if ($LASTEXITCODE -ne 0) { + throw "$Action failed with exit code $LASTEXITCODE." + } } $resolvedApk = (Resolve-Path -LiteralPath $ApkPath).Path -$baseUri = [Uri]($ArgusBaseUrl.TrimEnd("/")) -$slugEscaped = [Uri]::EscapeDataString($Slug) +$sshTarget = "$SshUser@$SshHost" +$remoteFile = "/tmp/$Slug-$Version.apk" +$manifestUrl = "$($ArgusBaseUrl.TrimEnd("/"))/api/apps/$Slug/manifest?platform=$Platform&channel=$Channel" -Add-Type -AssemblyName System.Net.Http +& scp "$resolvedApk" "${sshTarget}:$remoteFile" +Assert-LastExitCode "Copy APK to Argus host" -$handler = [System.Net.Http.HttpClientHandler]::new() -$handler.CookieContainer = [System.Net.CookieContainer]::new() -$client = [System.Net.Http.HttpClient]::new($handler) -$client.BaseAddress = $baseUri +$nameB64 = ConvertTo-Base64Utf8 $Name +$summaryB64 = ConvertTo-Base64Utf8 $Summary +$descriptionB64 = ConvertTo-Base64Utf8 $Description +$repositoryUrlB64 = ConvertTo-Base64Utf8 $RepositoryUrl +$homepageUrlB64 = ConvertTo-Base64Utf8 $HomepageUrl +$notesB64 = ConvertTo-Base64Utf8 $Notes -function New-JsonContent([object]$Value) { - $json = $Value | ConvertTo-Json -Depth 8 - return [System.Net.Http.StringContent]::new($json, [Text.Encoding]::UTF8, "application/json") +$remoteScript = @" +set -euo pipefail + +export ARGUS_DATA="$ArgusData" +export SOURCE_FILE="$remoteFile" + +export ARGUS_SLUG="$Slug" +export ARGUS_NAME="`$(printf '%s' '$nameB64' | base64 -d)" +export ARGUS_SUMMARY="`$(printf '%s' '$summaryB64' | base64 -d)" +export ARGUS_DESCRIPTION="`$(printf '%s' '$descriptionB64' | base64 -d)" +export ARGUS_REPOSITORY_URL="`$(printf '%s' '$repositoryUrlB64' | base64 -d)" +export ARGUS_HOMEPAGE_URL="`$(printf '%s' '$homepageUrlB64' | base64 -d)" +export ARGUS_IS_LISTED="1" + +export ARGUS_VERSION="$Version" +export ARGUS_CHANNEL="$Channel" +export ARGUS_PLATFORM="$Platform" +export ARGUS_PACKAGE_KIND="$PackageKind" +export ARGUS_NOTES="`$(printf '%s' '$notesB64' | base64 -d)" + +trap 'rm -f "$SOURCE_FILE"' EXIT + +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()).upper() +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()).upper() + 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=$ArgusBaseUrl/api/apps/{slug}/manifest?platform={platform}&channel={channel}") +PY + +curl -sS "http://127.0.0.1:5105/api/apps/`$ARGUS_SLUG/manifest?platform=`$ARGUS_PLATFORM&channel=`$ARGUS_CHANNEL" >/dev/null +"@ + +$remoteScript | & ssh "$sshTarget" "bash -s" +Assert-LastExitCode "Publish release on Argus host" + +$manifest = Invoke-RestMethod -Uri $manifestUrl -Headers @{ Accept = "application/json" } +if ($manifest.release.version -ne $Version) { + throw "Argus manifest verification failed: expected version $Version, got $($manifest.release.version)." } -function Read-ResponseBody($Response) { - return $Response.Content.ReadAsStringAsync().GetAwaiter().GetResult() -} - -function Assert-Success($Response, [string]$Action) { - if (-not $Response.IsSuccessStatusCode) { - $body = Read-ResponseBody $Response - throw "$Action failed: HTTP $([int]$Response.StatusCode) $body" - } -} - -try { - $loginPayload = @{ - username = $Username - password = $Password - } - $loginResponse = $client.PostAsync("/api/admin/session/login", (New-JsonContent $loginPayload)).GetAwaiter().GetResult() - Assert-Success $loginResponse "Argus login" - - $metadataPayload = @{ - name = $Name - summary = $Summary - description = $Description - androidPackageName = $AndroidPackageName - repositoryUrl = $null - homepageUrl = $null - isListed = $true - } - $metadataResponse = $client.PutAsync("/api/admin/apps/$slugEscaped", (New-JsonContent $metadataPayload)).GetAwaiter().GetResult() - Assert-Success $metadataResponse "Argus app metadata update" - - $form = [System.Net.Http.MultipartFormDataContent]::new() - $form.Add([System.Net.Http.StringContent]::new($Version), "Version") - $form.Add([System.Net.Http.StringContent]::new($Channel), "Channel") - $form.Add([System.Net.Http.StringContent]::new($Platform), "Platform") - $form.Add([System.Net.Http.StringContent]::new($PackageKind), "PackageKind") - $form.Add([System.Net.Http.StringContent]::new($AndroidVersionCode.ToString()), "AndroidVersionCode") - $form.Add([System.Net.Http.StringContent]::new($Notes), "Notes") - - $fileStream = [IO.File]::OpenRead($resolvedApk) - $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", [IO.Path]::GetFileName($resolvedApk)) - - $releaseResponse = $client.PostAsync("/api/admin/apps/$slugEscaped/releases", $form).GetAwaiter().GetResult() - Assert-Success $releaseResponse "Argus release upload" - - $releaseBody = Read-ResponseBody $releaseResponse - Write-Output $releaseBody -} finally { - if ($fileStream) { - $fileStream.Dispose() - } - if ($form) { - $form.Dispose() - } - $client.Dispose() - $handler.Dispose() -} +Write-Output "Published $Slug $Version to $manifestUrl"