diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f10ce4f..69885ca 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -142,6 +142,17 @@ jobs: $apkSha = (Get-FileHash $apk -Algorithm SHA256).Hash.ToLowerInvariant() $commit = (git rev-parse HEAD).Trim() $download = "https://github.com/$($env:REPOSITORY)/releases/download/$tag/Telegram-WSP-release.apk" + # Feed SDK is derived from the actual prepared Telegram modules. + $sdkVersions = @() + foreach ($module in @('TMessagesProj', 'TMessagesProj_AppStandalone')) { + $gradle = Get-Content ".work/telegram/$module/build.gradle" -Raw + $sdkMatches = [regex]::Matches($gradle, '(?m)^[ \t]*minSdkVersion[ \t]+(\d+)[ \t]*\r?\n') + if ($sdkMatches.Count -ne 1) { throw "Ambiguous minSdkVersion in $module." } + $sdkVersions += [int]$sdkMatches[0].Groups[1].Value + } + if ($sdkVersions[0] -ne $sdkVersions[1] -or $sdkVersions[0] -notin @(21, 23)) { + throw "Unsupported or inconsistent minSdkVersion: $($sdkVersions -join ', ')." + } $latest = [ordered]@{ versionName = [string]$upstream.telegramVersion @@ -155,7 +166,7 @@ jobs: apk = $download apkSha256 = $apkSha publishedAt = (Get-Date).ToUniversalTime().ToString('o') - minimumSdk = 21 + minimumSdk = $sdkVersions[0] packageId = 'org.telegram.messenger.web' } [IO.File]::WriteAllText('dist/latest.json', ($latest | ConvertTo-Json -Depth 4) + [Environment]::NewLine) @@ -215,26 +226,23 @@ jobs: ) $assets += @(Get-ChildItem 'dist/source' -File | ForEach-Object { $_.FullName }) + # Published tags and their signed APK/feed are immutable. A failed + # build may be retried only while its tag has not been published. gh release view $env:RELEASE_TAG --repo $env:GITHUB_REPOSITORY *> $null - $exists = $LASTEXITCODE -eq 0 - - if ($exists) { - gh release upload $env:RELEASE_TAG @assets --clobber --repo $env:GITHUB_REPOSITORY - if ($LASTEXITCODE -ne 0) { throw 'gh release upload failed.' } - gh release edit $env:RELEASE_TAG --title $env:RELEASE_TITLE --notes-file 'dist/release-notes.md' --repo $env:GITHUB_REPOSITORY - if ($LASTEXITCODE -ne 0) { throw 'gh release edit failed.' } - } else { - $arguments = @('release', 'create', $env:RELEASE_TAG) + $assets + @( - '--title', $env:RELEASE_TITLE, - '--notes-file', 'dist/release-notes.md', - '--target', $env:RELEASE_COMMIT, - '--repo', $env:GITHUB_REPOSITORY - ) - if ($env:PRERELEASE -eq 'true') { $arguments += '--prerelease' } - & gh @arguments - if ($LASTEXITCODE -ne 0) { throw 'gh release create failed.' } + if ($LASTEXITCODE -eq 0) { + throw "Release $env:RELEASE_TAG already exists; refusing to overwrite signed assets." } + $arguments = @('release', 'create', $env:RELEASE_TAG) + $assets + @( + '--title', $env:RELEASE_TITLE, + '--notes-file', 'dist/release-notes.md', + '--target', $env:RELEASE_COMMIT, + '--repo', $env:GITHUB_REPOSITORY + ) + if ($env:PRERELEASE -eq 'true') { $arguments += '--prerelease' } + & gh @arguments + if ($LASTEXITCODE -ne 0) { throw 'gh release create failed.' } + - name: Release summary shell: pwsh env: diff --git a/.github/workflows/trusted-ci.yml b/.github/workflows/trusted-ci.yml index 4bd2a98..271ae3f 100644 --- a/.github/workflows/trusted-ci.yml +++ b/.github/workflows/trusted-ci.yml @@ -32,9 +32,13 @@ jobs: run: ./scripts/ci.ps1 integration: - name: Reproduce Telegram overlay + name: Overlay and APK (${{ matrix.telegram }}) runs-on: ubuntu-latest - timeout-minutes: 30 + timeout-minutes: 60 + strategy: + fail-fast: false + matrix: + telegram: [pinned, telegram-13-0-3] steps: - name: Checkout @@ -42,6 +46,16 @@ jobs: with: persist-credentials: false + - name: Select Telegram 13.0.3 regression fixture + if: matrix.telegram == 'telegram-13-0-3' + shell: pwsh + run: | + $upstream = Get-Content config/upstream.json -Raw | ConvertFrom-Json + $upstream.pinnedCommit = '0aae959806c57be8ad457980f2a5c6277ff18635' + $upstream.telegramVersion = '13.0.3' + $upstream.telegramBuild = 7183 + $upstream | ConvertTo-Json -Depth 4 | Set-Content config/upstream.json + - name: Set up Java uses: actions/setup-java@v5 with: @@ -59,7 +73,7 @@ jobs: - name: Install Android SDK components shell: bash - run: sdkmanager "platforms;android-35" "ndk;27.2.12479018" + run: sdkmanager "platforms;android-35" "platforms;android-36" "ndk;27.2.12479018" - name: Set up Gradle uses: gradle/actions/setup-gradle@v4 @@ -73,3 +87,35 @@ jobs: - name: Re-run repository checks on prepared tree shell: pwsh run: ./scripts/ci.ps1 + + - name: Reject incompatible SDK anchor + shell: pwsh + run: | + $path = '.work/telegram/TMessagesProj/build.gradle' + $original = Get-Content $path -Raw + $pattern = '(?m)^ minSdkVersion (21|23)$' + $modified = [regex]::Replace($original, $pattern, ' minSdkVersion 42') + if ($modified -eq $original) { throw 'Test fixture could not change minSdk.' } + try { + Set-Content $path $modified -NoNewline + $rejected = $false + try { + ./scripts/apply-integration.ps1 + } catch { + if ($_.Exception.Message -notmatch 'minSdk/targetSdk contract is unsupported') { + throw "Unexpected rejection: $($_.Exception.Message)" + } + $rejected = $true + } + if (-not $rejected) { throw 'Unsupported API 42 integration was accepted.' } + } finally { + Set-Content $path $original -NoNewline + } + + - name: Compile Telegram-WSP prototype APK + shell: pwsh + env: + # Public Telegram upstream fallback values; compile-only CI, no release signing. + TELEGRAM_API_ID: '4' + TELEGRAM_API_HASH: '014b35b6184100b085b0d0572f9b5103' + run: ./scripts/build-apk.ps1 -SkipPrepare diff --git a/.github/workflows/upstream-sync.yml b/.github/workflows/upstream-sync.yml index 3151380..faf084b 100644 --- a/.github/workflows/upstream-sync.yml +++ b/.github/workflows/upstream-sync.yml @@ -105,6 +105,14 @@ jobs: shell: pwsh run: ./scripts/ci.ps1 + - name: Compile candidate APK before changing main + if: steps.upstream.outputs.changed == 'true' + shell: pwsh + env: + TELEGRAM_API_ID: ${{ secrets.TELEGRAM_API_ID }} + TELEGRAM_API_HASH: ${{ secrets.TELEGRAM_API_HASH }} + run: ./scripts/build-apk.ps1 -SkipPrepare + - name: Commit validated upstream pin if: steps.upstream.outputs.changed == 'true' id: commit @@ -129,54 +137,33 @@ jobs: REQUESTED_REVISION: ${{ inputs.revision }} run: | $config = Get-Content 'config/upstream.json' -Raw | ConvertFrom-Json - $requestedRevision = if ($null -eq $env:REQUESTED_REVISION) { '' } else { $env:REQUESTED_REVISION.Trim() } - if (-not [string]::IsNullOrWhiteSpace($requestedRevision) -and $requestedRevision -notmatch '^\d+$') { - throw 'revision must contain decimal digits only.' - } - - if ([string]::IsNullOrWhiteSpace($requestedRevision)) { - $releaseJson = & gh release list --repo $env:GITHUB_REPOSITORY --limit 100 --json tagName,isDraft,isPrerelease - if ($LASTEXITCODE -ne 0) { throw 'Failed to list GitHub Releases while resolving the WSP revision.' } - $releaseData = (($releaseJson -join [Environment]::NewLine) | ConvertFrom-Json) - $knownRevisions = @( - foreach ($release in $releaseData) { - if ($release.isDraft -or $release.isPrerelease) { continue } - if ([string]$release.tagName -match '^v.+-wsp\.(\d+)$') { - [int]$Matches[1] - } - } - ) - $revision = if ($knownRevisions.Count -eq 0) { - '1' - } else { - [string](($knownRevisions | Measure-Object -Maximum).Maximum) - } - } else { - $revision = $requestedRevision - } - $changed = '${{ steps.upstream.outputs.changed }}' -eq 'true' $releaseRef = if ($changed) { '${{ steps.commit.outputs.sha }}' } else { (git rev-parse HEAD).Trim() } - $tag = "v$($config.telegramVersion)-wsp.$revision" + if ($releaseRef -notmatch '^[0-9a-f]{40}$') { throw 'Invalid validated release ref.' } - & gh release view $tag --repo $env:GITHUB_REPOSITORY *> $null - $releaseExists = $? + # Include draft and prerelease tags so an occupied tag can never be overwritten. + $releaseJson = & gh release list --repo $env:GITHUB_REPOSITORY --limit 100 --json tagName,isDraft,isPrerelease + if ($LASTEXITCODE -ne 0) { throw 'Failed to enumerate GitHub Releases.' } + $releaseList = $releaseJson -join [Environment]::NewLine $forced = $env:FORCE_RELEASE -eq 'true' - $required = $forced -or ($changed -and -not $releaseExists) - - "release_required=$($required.ToString().ToLowerInvariant())" >> $env:GITHUB_OUTPUT - "release_ref=$releaseRef" >> $env:GITHUB_OUTPUT - "revision=$revision" >> $env:GITHUB_OUTPUT - - Write-Host "Tag: $tag" - Write-Host "Release exists: $releaseExists" - Write-Host "Release required: $required" - Write-Host "Release ref: $releaseRef" - - # A missing release is expected on first publication. Do not propagate - # the non-zero exit code from 'gh release view' to the pwsh process. - exit 0 - + $requested = if ($null -eq $env:REQUESTED_REVISION) { '' } else { $env:REQUESTED_REVISION.Trim() } + $decisionText = & ./scripts/resolve-upstream-release.ps1 ` + -TelegramVersion ([string]$config.telegramVersion) ` + -UpstreamChanged $changed ` + -ForceRelease $forced ` + -RequestedRevision $requested ` + -ReleaseListJson $releaseList ` + -ReleaseRef $releaseRef + $decision = $decisionText | ConvertFrom-Json + + "release_required=$($decision.releaseRequired.ToString().ToLowerInvariant())" >> $env:GITHUB_OUTPUT + "release_ref=$($decision.releaseRef)" >> $env:GITHUB_OUTPUT + "revision=$($decision.revision)" >> $env:GITHUB_OUTPUT + + Write-Host "Release tag: $($decision.tag)" + Write-Host "Reason: $($decision.reason)" + Write-Host "Release required: $($decision.releaseRequired)" + Write-Host "Release ref: $($decision.releaseRef)" release: name: Publish validated Telegram-WSP update needs: sync diff --git a/README.md b/README.md index cb8e4e1..ae9d97a 100644 --- a/README.md +++ b/README.md @@ -36,12 +36,12 @@ Telegram-WSP использует собственные название, packa Стадия: **MVP**. -Текущий публичный релиз: **[v12.10.5-wsp.2](../../releases/tag/v12.10.5-wsp.2)**. +Текущий публичный релиз: **[v12.10.6-wsp.2](../../releases/tag/v12.10.6-wsp.2)**. | Компонент | Текущее состояние | |---|---| -| Telegram upstream | 12.10.5, build 7105 | -| Telegram commit | `dc780e81ed1261c369c27870e8e0999a1eb0b600` | +| Telegram upstream | 12.10.6, build 7112 | +| Telegram commit | `f2908b14133bbffbf7ab04f641ecb5bfaf533242` | | tgwsproxy-core commit | `8016d1b56210e8edc69da9d637416b70b30971a8` | | Android package | `org.telegram.messenger.web` | | Встроенный TgWsProxy | Работает через локальный listener | @@ -50,7 +50,7 @@ Telegram-WSP использует собственные название, packa | Автообновление приложения | Реализовано через GitHub Releases | | Upstream sync | Автоматическая проверка DrKLO/Telegram с integration gates | -Релиз `v12.10.5-wsp.2` включает новый экран управления маршрутами, поддержку Cloudflare-доменов, WARP/AmneziaWG-профиля и Worker pools. Production APK публикуется вместе с контрольными суммами и точными исходными компонентами сборки. +Релиз `v12.10.6-wsp.2` включает новый экран управления маршрутами, поддержку Cloudflare-доменов, WARP/AmneziaWG-профиля и Worker pools. Production APK публикуется вместе с контрольными суммами и точными исходными компонентами сборки. ## Возможности @@ -83,7 +83,7 @@ Telegram-WSP использует собственные название, packa Для готового APK: -- Android **5.0 / API 21** или новее; +- Для последней опубликованной сборки Telegram 12.10.6: Android **5.0 / API 21** или новее. Telegram upstream 13.0.3 требует **Android 6.0 / API 23**; будущие релизы на его основе не должны заявлять поддержку Android 5.0; - доступ в интернет; - разрешение Android на установку APK не из магазина. @@ -221,9 +221,11 @@ Telegram-WSP проверяет: https://github.com/Regstar2/telegram-wsp/releases/latest/download/latest.json ``` -не чаще одного раза в 12 часов. +автоматически не чаще одного раза в 12 часов **после успешной проверки**. После сетевой ошибки повторная автоматическая попытка допускается через 10 минут; кнопка ручной проверки не зависит от этого интервала. -Если `versionCode` новой сборки выше установленного, приложение предлагает обновление. После согласия APK скачивается, проверяется и передаётся системному installer. +В интерфейсе «Встроенный прокси» доступны «Проверить обновления Telegram-WSP» и «Открыть релизы Telegram-WSP». Последний пункт открывает официальный GitHub Releases в браузере, если прямой HTTPS-доступ updater отсутствует: локальный MTProto frontend не является HTTP(S)-прокси. Автоматическая проверка возобновляется при возврате Telegram на экран. + +Если `versionCode` новой сборки выше установленного, приложение предлагает обновление (с точным WSP release tag и ссылкой на release notes). После согласия APK скачивается, проверяется и передаётся системному installer. Нумерация Telegram-WSP учитывает как Telegram build, так и WSP revision: @@ -242,6 +244,8 @@ versionCode = telegramBuild * 1000 + wspRevision * 10 + 9 Если overlay не применяется или проверки не проходят, новая версия Telegram не должна становиться публичным Telegram-WSP-релизом. +Workflow запускается по расписанию без участия пользователя. Если upstream pin уже обновлён, но подписанный stable-релиз ещё не опубликован (например, предыдущий Windows release job завершился ошибкой), следующая проверка повторно запускает release-сборку. Уже существующий stable tag/подписанный APK не перезаписывается; WSP revision наследуется от последней опубликованной стабильной ревизии. + ## Разработка Рабочий Telegram checkout и core создаются локально: diff --git a/integration/README.md b/integration/README.md index 8de812f..c666b6d 100644 --- a/integration/README.md +++ b/integration/README.md @@ -162,6 +162,15 @@ TELEGRAM_API_HASH= `afatDebug` не используется: соответствующий library build включает `DEBUG_VERSION=true` и `DEBUG_PRIVATE_VERSION=true`. +## Android updater и сетевой маршрут + +- `TgWsProxyBootstrap` проверяет `latest.json` и скачивает APK стандартным `HttpURLConnection` поверх HTTPS. Это обычный Android/system HTTP(S) route; он **не использует** локальный MTProto frontend `127.0.0.1:1443` как HTTP proxy. +- Разрешены GitHub Releases и GitHub release asset CDN; прочие HTTPS redirect destinations блокируются, а SHA-256, package, signing certificate и `versionCode` повторно проверяются перед installer. +- Если GitHub недоступен в текущей сети, ручная проверка сообщает ошибку. Пункт «Открыть релизы Telegram-WSP» переводит на официальные GitHub Releases через установленный браузер, который может использовать собственную поддерживаемую сетевую маршрутизацию. Успешность этого обходного пути зависит от среды пользователя. +- Автоматическая проверка: интервал 12 часов после успешно проверенного feed и минимум 10 минут после попытки; ручная проверка доступна в настройках встроенного прокси. Найденный manifest хранится локально на время интервала и повторно валидируется после перезапуска процесса. +- Для Telegram 12.10.6 принят minSdk 21, для Telegram 13.0.3 — minSdk 23. Overlay не понижает upstream SDK. Модель совместимости release проверяется по `aapt`, но upgrade и сохранность данных требуют Android device acceptance. +- Эти проверки в `scripts/ci.ps1` защищают контракт исходников. Они не заменяют instrumented UI/network tests, подписанную release-сборку и установку поверх предыдущего release. + ## Release signing Постоянный release key создаётся один раз: diff --git a/integration/telegram/TgWsProxyBootstrap.java b/integration/telegram/TgWsProxyBootstrap.java index 53f909d..291e50b 100644 --- a/integration/telegram/TgWsProxyBootstrap.java +++ b/integration/telegram/TgWsProxyBootstrap.java @@ -1,5 +1,7 @@ package org.telegram.messenger; +import android.app.Activity; +import android.app.Application; import android.content.Context; import android.content.Intent; import android.content.SharedPreferences; @@ -10,6 +12,7 @@ import android.os.Build; import android.os.Handler; import android.os.Looper; +import android.os.Bundle; import android.provider.Settings; import android.widget.Toast; @@ -42,7 +45,9 @@ public final class TgWsProxyBootstrap { private static final String KEY_RUNTIME_CONFIG = "runtime_config"; private static final String KEY_MANAGED_PROXY = "managed_proxy"; private static final String KEY_UPDATE_ENABLED = "update_enabled"; - private static final String KEY_UPDATE_LAST_CHECK = "update_last_check"; + private static final String KEY_UPDATE_LAST_SUCCESS = "update_last_success"; + private static final String KEY_UPDATE_LAST_ATTEMPT = "update_last_attempt"; + private static final String KEY_UPDATE_PENDING_MANIFEST = "update_pending_manifest"; private static final String HOST = "127.0.0.1"; private static final int PORT = 1443; private static final String LEGACY_RUNTIME_CONFIG = "@mtproto_worker_preconnect=1"; @@ -53,6 +58,8 @@ public final class TgWsProxyBootstrap { private static final String UPDATE_APK_URL_PREFIX = "https://github.com/Regstar2/telegram-wsp/releases/download/"; private static final long UPDATE_CHECK_INTERVAL_MS = 12L * 60L * 60L * 1000L; + private static final long UPDATE_RETRY_DELAY_MS = 10L * 60L * 1000L; + private static final String RELEASES_URL = "https://github.com/Regstar2/telegram-wsp/releases"; private static final int NETWORK_CONNECT_TIMEOUT_MS = 15_000; private static final int NETWORK_READ_TIMEOUT_MS = 30_000; private static final int UPDATE_METADATA_LIMIT_BYTES = 64 * 1024; @@ -60,6 +67,9 @@ public final class TgWsProxyBootstrap { private static final char[] HEX = "0123456789abcdef".toCharArray(); private static boolean initialized; + private static boolean checking; + private static UpdateInfo pendingUpdate; + private static long promptedVersionCode = -1L; private TgWsProxyBootstrap() { } @@ -73,6 +83,15 @@ public static void start(Context context) { } Context appContext = context.getApplicationContext(); + TgWsProxyController.registerUpdateActions(new TgWsProxyController.UpdateActions() { + @Override public void check(Context context) { + TgWsProxyBootstrap.checkForUpdates(context); + } + @Override public void openReleases(Context context) { + TgWsProxyBootstrap.openReleases(context); + } + }); + Thread bootstrapThread = new Thread( () -> startInBackground(appContext), @@ -81,12 +100,49 @@ public static void start(Context context) { bootstrapThread.setDaemon(true); bootstrapThread.start(); - Thread updateThread = new Thread( - () -> checkForUpdatesInBackground(appContext), - "TelegramWSPUpdateCheck" - ); - updateThread.setDaemon(true); - updateThread.start(); + if (appContext instanceof Application) { + ((Application) appContext).registerActivityLifecycleCallbacks( + new Application.ActivityLifecycleCallbacks() { + @Override public void onActivityCreated(Activity a, Bundle state) {} + @Override public void onActivityStarted(Activity a) {} + @Override public void onActivityPaused(Activity a) {} + @Override public void onActivityStopped(Activity a) {} + @Override public void onActivitySaveInstanceState(Activity a, Bundle state) {} + @Override public void onActivityDestroyed(Activity a) {} + @Override public void onActivityResumed(Activity a) { + if (a instanceof LaunchActivity) { + postToMain(() -> offerPendingUpdate(a)); + requestUpdateCheck(appContext, false); + } + } + }); + } + restorePendingUpdate(appContext); + requestUpdateCheck(appContext, false); + } + + private static void restorePendingUpdate(Context context) { + SharedPreferences prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE); + long now = System.currentTimeMillis(); + if (!isCoolingDown(now, prefs.getLong(KEY_UPDATE_LAST_SUCCESS, 0L), + UPDATE_CHECK_INTERVAL_MS)) { + prefs.edit().remove(KEY_UPDATE_PENDING_MANIFEST).apply(); + return; + } + String saved = prefs.getString(KEY_UPDATE_PENDING_MANIFEST, null); + if (saved == null) return; + try { + UpdateInfo update = validateUpdate(new JSONObject(saved)); + if (update.versionCode > getCurrentVersionCode(context) + && update.minimumSdk <= Build.VERSION.SDK_INT) { + synchronized (TgWsProxyBootstrap.class) { pendingUpdate = update; } + } else { + prefs.edit().remove(KEY_UPDATE_PENDING_MANIFEST).apply(); + } + } catch (Throwable error) { + prefs.edit().remove(KEY_UPDATE_PENDING_MANIFEST).apply(); + FileLog.e("Telegram-WSP saved update manifest invalid: " + error); + } } private static void startInBackground(Context appContext) { @@ -97,42 +153,160 @@ private static void startInBackground(Context appContext) { } } - private static void checkForUpdatesInBackground(Context appContext) { + private static void postToMain(Runnable runnable) { + Handler handler = ApplicationLoader.applicationHandler; + (handler == null ? new Handler(Looper.getMainLooper()) : handler).post(runnable); + } + + private static void message(Context context, String text) { + postToMain(() -> Toast.makeText(context, text, Toast.LENGTH_LONG).show()); + } + + public static void checkForUpdates(Context context) { + requestUpdateCheck(context.getApplicationContext(), true); + } + + public static void openReleases(Context context) { try { - SharedPreferences preferences = appContext.getSharedPreferences(PREFS, Context.MODE_PRIVATE); - if (!preferences.getBoolean(KEY_UPDATE_ENABLED, true)) { + Intent intent = new Intent(Intent.ACTION_VIEW, Uri.parse(RELEASES_URL)); + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + context.startActivity(intent); + } catch (Throwable error) { + FileLog.e("Could not open Telegram-WSP releases: " + error); + message(context, "Could not open GitHub Releases."); + } + } + + private static boolean isCoolingDown(long now, long previous, long interval) { + return previous > 0L && now >= previous && now - previous < interval; + } + + private static void requestUpdateCheck(Context context, boolean manual) { + SharedPreferences prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE); + if (!manual && !prefs.getBoolean(KEY_UPDATE_ENABLED, true)) { + return; + } + synchronized (TgWsProxyBootstrap.class) { + if (checking) { + if (manual) message(context, "Telegram-WSP: update check already in progress."); return; } - long now = System.currentTimeMillis(); - long lastCheck = preferences.getLong(KEY_UPDATE_LAST_CHECK, 0L); - if (lastCheck > 0L && now - lastCheck < UPDATE_CHECK_INTERVAL_MS) { + if (!manual && (isCoolingDown(now, + prefs.getLong(KEY_UPDATE_LAST_SUCCESS, 0L), UPDATE_CHECK_INTERVAL_MS) + || isCoolingDown(now, + prefs.getLong(KEY_UPDATE_LAST_ATTEMPT, 0L), UPDATE_RETRY_DELAY_MS))) { return; } - preferences.edit().putLong(KEY_UPDATE_LAST_CHECK, now).apply(); + checking = true; + prefs.edit().putLong(KEY_UPDATE_LAST_ATTEMPT, now).apply(); + } - String metadataJson = readSmallHttpsResource(UPDATE_FEED_URL); - JSONObject metadata = new JSONObject(metadataJson); - long remoteVersionCode = metadata.getLong("versionCode"); - long currentVersionCode = getCurrentVersionCode(appContext); - if (remoteVersionCode <= currentVersionCode) { - return; + Thread thread = new Thread(() -> { + try { + JSONObject metadata = new JSONObject(readSmallHttpsResource(UPDATE_FEED_URL)); + UpdateInfo update = validateUpdate(metadata); + long remoteVersionCode = update.versionCode; + long currentVersionCode = getCurrentVersionCode(context); + SharedPreferences.Editor success = prefs.edit() + .putLong(KEY_UPDATE_LAST_SUCCESS, System.currentTimeMillis()); + if (remoteVersionCode > currentVersionCode + && update.minimumSdk <= Build.VERSION.SDK_INT) { + success.putString(KEY_UPDATE_PENDING_MANIFEST, metadata.toString()); + } else { + success.remove(KEY_UPDATE_PENDING_MANIFEST); + } + success.apply(); + if (remoteVersionCode <= currentVersionCode) { + synchronized (TgWsProxyBootstrap.class) { pendingUpdate = null; } + if (manual) message(context, "Telegram-WSP is up to date."); + } else if (update.minimumSdk > Build.VERSION.SDK_INT) { + synchronized (TgWsProxyBootstrap.class) { pendingUpdate = null; } + if (manual) message(context, update.tag + " requires Android API " + update.minimumSdk); + } else { + synchronized (TgWsProxyBootstrap.class) { + pendingUpdate = update; + if (manual) promptedVersionCode = -1L; + } + postToMain(() -> { + if (LaunchActivity.instance != null) { + offerPendingUpdate(LaunchActivity.instance); + } else if (manual) { + message(context, "Update found. Open Telegram to see the update prompt."); + } + }); + } + } catch (Throwable error) { + FileLog.e("Telegram-WSP update check failed: " + error); + if (manual) { + message(context, "Update check failed. Retry or open GitHub Releases."); + } + } finally { + synchronized (TgWsProxyBootstrap.class) { checking = false; } } + }, "TelegramWSPUpdateCheck"); + thread.setDaemon(true); + thread.start(); + } - String versionName = metadata.optString("versionName", String.valueOf(remoteVersionCode)); - String apkUrl = metadata.getString("apk"); - String apkSha256 = metadata.getString("apkSha256").toLowerCase(java.util.Locale.US); - if (!apkUrl.startsWith(UPDATE_APK_URL_PREFIX)) { - throw new IllegalStateException("Unexpected update APK URL: " + apkUrl); - } - if (!apkSha256.matches("[0-9a-f]{64}")) { - throw new IllegalStateException("Invalid update APK SHA-256"); + private static UpdateInfo validateUpdate(JSONObject metadata) throws Exception { + String version = metadata.getString("versionName"); + String tag = metadata.getString("tag"); + int revision = metadata.getInt("revision"); + long build = metadata.getLong("telegramBuild"); + long code = metadata.getLong("versionCode"); + int minSdk = metadata.getInt("minimumSdk"); + String apk = metadata.getString("apk"); + String hash = metadata.getString("apkSha256").toLowerCase(java.util.Locale.US); + if (!version.matches("[0-9]+\\.[0-9]+\\.[0-9]+") + || revision < 1 || revision > 99 + || !tag.equals("v" + version + "-wsp." + revision) + || build <= 0L || build > 2_000_000L + || code != build * 1000L + revision * 10L + 9L + || minSdk < 21 || minSdk > 100 + || !"org.telegram.messenger.web".equals(metadata.getString("packageId")) + || !apk.equals(UPDATE_APK_URL_PREFIX + tag + "/Telegram-WSP-release.apk") + || !hash.matches("[0-9a-f]{64}")) { + throw new SecurityException("Invalid stable Telegram-WSP update manifest"); + } + return new UpdateInfo(code, version, tag, apk, hash, minSdk); + } + + private static void offerPendingUpdate(Activity activity) { + if (!(activity instanceof LaunchActivity) || activity.isFinishing() + || (Build.VERSION.SDK_INT >= 17 && activity.isDestroyed())) return; + final UpdateInfo update; + synchronized (TgWsProxyBootstrap.class) { + update = pendingUpdate; + if (update == null || promptedVersionCode == update.versionCode) return; + promptedVersionCode = update.versionCode; + } + try { + new AlertDialog.Builder(activity) + .setTitle(LocaleController.getString(R.string.AppUpdate)) + .setMessage("Telegram-WSP " + update.tag + + " is available. Android will request installation after APK verification.") + .setPositiveButton(LocaleController.getString(R.string.AppUpdateNow), + (dialog, which) -> downloadAndInstallInBackground(activity.getApplicationContext(), update)) + .setNeutralButton("Release notes", (dialog, which) -> openReleaseNotes(activity, update)) + .setNegativeButton(LocaleController.getString(R.string.Cancel), null) + .show(); + } catch (Throwable error) { + synchronized (TgWsProxyBootstrap.class) { + if (promptedVersionCode == update.versionCode) promptedVersionCode = -1L; } + FileLog.e("Telegram-WSP could not show update prompt: " + error); + } + } - UpdateInfo update = new UpdateInfo(remoteVersionCode, versionName, apkUrl, apkSha256); - offerUpdateWhenActivityReady(appContext, update, 0); + private static void openReleaseNotes(Context context, UpdateInfo update) { + try { + Intent intent = new Intent(Intent.ACTION_VIEW, Uri.parse(RELEASES_URL + "/tag/" + update.tag)); + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + context.startActivity(intent); } catch (Throwable error) { - FileLog.e("Telegram-WSP update check failed: " + error); + FileLog.e("Telegram-WSP could not open release notes: " + error); + message(context, "Could not open release notes."); } } @@ -164,19 +338,40 @@ private static String readSmallHttpsResource(String url) throws Exception { } private static HttpURLConnection openHttpsConnection(String url) throws Exception { - URL parsed = new URL(url); - if (!"https".equalsIgnoreCase(parsed.getProtocol())) { - throw new IllegalArgumentException("Only HTTPS update URLs are allowed"); - } - - HttpURLConnection connection = (HttpURLConnection) parsed.openConnection(); - connection.setInstanceFollowRedirects(true); - connection.setConnectTimeout(NETWORK_CONNECT_TIMEOUT_MS); - connection.setReadTimeout(NETWORK_READ_TIMEOUT_MS); - connection.setUseCaches(false); - connection.setRequestProperty("Cache-Control", "no-cache"); - connection.setRequestProperty("User-Agent", "Telegram-WSP Android Updater"); - return connection; + URL current = new URL(url); + // Only GitHub release paths and GitHub's release asset CDN can serve updater bytes. + for (int redirects = 0; redirects <= 5; redirects++) { + if (!"https".equalsIgnoreCase(current.getProtocol()) + || current.getUserInfo() != null + || (current.getPort() != -1 && current.getPort() != 443)) { + throw new SecurityException("Update URL must use HTTPS on port 443"); + } + String host = current.getHost().toLowerCase(java.util.Locale.US); + if (!(("github.com".equals(host) + && current.getPath().startsWith("/Regstar2/telegram-wsp/releases/")) + || "release-assets.githubusercontent.com".equals(host))) { + throw new SecurityException("Update redirect leads to an untrusted host/path"); + } + HttpURLConnection connection = (HttpURLConnection) current.openConnection(); + connection.setInstanceFollowRedirects(false); + connection.setConnectTimeout(NETWORK_CONNECT_TIMEOUT_MS); + connection.setReadTimeout(NETWORK_READ_TIMEOUT_MS); + connection.setUseCaches(false); + connection.setRequestProperty("Cache-Control", "no-cache"); + connection.setRequestProperty("User-Agent", "Telegram-WSP Android Updater"); + int response = connection.getResponseCode(); + if (response != 301 && response != 302 && response != 303 + && response != 307 && response != 308) { + return connection; + } + String location = connection.getHeaderField("Location"); + connection.disconnect(); + if (location == null || location.isEmpty()) { + throw new SecurityException("Update redirect has no Location"); + } + current = new URL(current, location); + } + throw new SecurityException("Too many update redirects"); } private static long getCurrentVersionCode(Context context) throws Exception { @@ -190,55 +385,17 @@ private static long getCurrentVersionCode(Context context) throws Exception { return packageInfo.versionCode; } - private static void offerUpdateWhenActivityReady( - Context appContext, - UpdateInfo update, - int attempt - ) { - Handler handler = ApplicationLoader.applicationHandler; - if (handler == null) { - handler = new Handler(Looper.getMainLooper()); - } - - Handler mainHandler = handler; - mainHandler.postDelayed(() -> { - if (LaunchActivity.instance == null) { - if (attempt < 30) { - offerUpdateWhenActivityReady(appContext, update, attempt + 1); - } - return; - } - - try { - new AlertDialog.Builder(LaunchActivity.instance) - .setTitle(LocaleController.getString(R.string.AppUpdate)) - .setMessage( - "Telegram-WSP " + update.versionName - + " is available. The APK will be verified before Android asks to install it." - ) - .setPositiveButton( - LocaleController.getString(R.string.AppUpdateNow), - (dialog, which) -> downloadAndInstallInBackground(appContext, update) - ) - .setNegativeButton(LocaleController.getString(R.string.Cancel), null) - .show(); - } catch (Throwable error) { - FileLog.e("Telegram-WSP could not show update prompt: " + error); - } - }, attempt == 0 ? 2500L : 1000L); - } - private static void downloadAndInstallInBackground(Context appContext, UpdateInfo update) { - Toast.makeText(appContext, "Telegram-WSP: downloading update...", Toast.LENGTH_SHORT).show(); + message(appContext, "Telegram-WSP: downloading update..."); Thread downloadThread = new Thread(() -> { try { File apk = getUpdateApkFile(appContext, update.versionCode); - if (!isVerifiedUpdateApk(appContext, apk, update.sha256)) { + if (!isVerifiedUpdateApk(appContext, apk, update)) { downloadUpdateApk(update.url, apk); } - if (!isVerifiedUpdateApk(appContext, apk, update.sha256)) { + if (!isVerifiedUpdateApk(appContext, apk, update)) { throw new SecurityException("Downloaded update APK did not pass verification"); } @@ -246,7 +403,13 @@ private static void downloadAndInstallInBackground(Context appContext, UpdateInf if (handler == null) { handler = new Handler(Looper.getMainLooper()); } - handler.post(() -> openUpdateInstaller(appContext, apk)); + handler.post(() -> { + if (isVerifiedUpdateApk(appContext, apk, update)) { + openUpdateInstaller(appContext, apk); + } else { + message(appContext, "Update verification failed; installation cancelled."); + } + }); } catch (Throwable error) { FileLog.e("Telegram-WSP update download failed: " + error); Handler handler = ApplicationLoader.applicationHandler; @@ -297,7 +460,7 @@ private static void downloadUpdateApk(String url, File destination) throws Excep throw new IllegalStateException("Update APK HTTP status " + status); } - int declaredLength = connection.getContentLength(); + long declaredLength = connection.getContentLength(); if (declaredLength > UPDATE_APK_LIMIT_BYTES) { throw new IllegalStateException("Update APK exceeds size limit"); } @@ -317,8 +480,8 @@ private static void downloadUpdateApk(String url, File destination) throws Excep output.getFD().sync(); } - if (total <= 0L) { - throw new IllegalStateException("Downloaded update APK is empty"); + if (total <= 0L || (declaredLength >= 0L && total != declaredLength)) { + throw new IllegalStateException("Downloaded update APK is empty or truncated"); } if (destination.exists() && !destination.delete()) { @@ -329,25 +492,29 @@ private static void downloadUpdateApk(String url, File destination) throws Excep } } finally { connection.disconnect(); - if (partial.exists() && !destination.exists()) { - partial.delete(); - } + if (partial.exists()) partial.delete(); } } private static boolean isVerifiedUpdateApk( Context context, File apk, - String expectedSha256 + UpdateInfo update ) { try { if (!apk.isFile()) { return false; } - if (!expectedSha256.equalsIgnoreCase(sha256(apk))) { + if (apk.length() <= 0L || apk.length() > UPDATE_APK_LIMIT_BYTES + || !update.sha256.equalsIgnoreCase(sha256(apk))) { return false; } - return hasSameSigningCertificate(context, apk); + PackageInfo candidate = context.getPackageManager().getPackageArchiveInfo(apk.getAbsolutePath(), 0); + long version = candidate == null ? -1L + : (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P + ? candidate.getLongVersionCode() : candidate.versionCode); + return version == update.versionCode && version > getCurrentVersionCode(context) + && hasSameSigningCertificate(context, apk); } catch (Throwable error) { FileLog.e("Telegram-WSP update verification failed: " + error); return false; @@ -530,14 +697,18 @@ private static boolean supportsArm64() { private static final class UpdateInfo { final long versionCode; final String versionName; + final String tag; final String url; final String sha256; + final int minimumSdk; - UpdateInfo(long versionCode, String versionName, String url, String sha256) { + UpdateInfo(long versionCode, String versionName, String tag, String url, String sha256, int minimumSdk) { this.versionCode = versionCode; this.versionName = versionName; + this.tag = tag; this.url = url; this.sha256 = sha256; + this.minimumSdk = minimumSdk; } } } diff --git a/integration/telegram/TgWsProxyController.java b/integration/telegram/TgWsProxyController.java index 88492f1..f0569a0 100644 --- a/integration/telegram/TgWsProxyController.java +++ b/integration/telegram/TgWsProxyController.java @@ -46,6 +46,41 @@ * UI code talks only to this class. Native/JNA details stay inside tgwsproxy-core. */ public final class TgWsProxyController { + + // The core UI cannot depend on the standalone application's Bootstrap class. + public interface UpdateActions { + void check(Context context); + void openReleases(Context context); + } + + private static volatile UpdateActions updateActions; + + public static void registerUpdateActions(UpdateActions actions) { + updateActions = actions; + } + + public static void checkForUpdates(Context context) { + UpdateActions actions = updateActions; + if (actions != null) { + actions.check(context); + } else { + new Handler(Looper.getMainLooper()).post(() -> + android.widget.Toast.makeText(context, "Telegram-WSP updater is unavailable.", + android.widget.Toast.LENGTH_LONG).show()); + } + } + + public static void openReleases(Context context) { + UpdateActions actions = updateActions; + if (actions != null) { + actions.openReleases(context); + } else { + new Handler(Looper.getMainLooper()).post(() -> + android.widget.Toast.makeText(context, "Telegram-WSP updater is unavailable.", + android.widget.Toast.LENGTH_LONG).show()); + } + } + public static final String ROUTE_AUTO = "auto"; public static final String ROUTE_CF_PROXY = "cf_proxy"; public static final String ROUTE_AWG = "awg_warp"; diff --git a/integration/telegram/TgWsProxySettingsActivity.java b/integration/telegram/TgWsProxySettingsActivity.java index a3d22c9..1b0a7db 100644 --- a/integration/telegram/TgWsProxySettingsActivity.java +++ b/integration/telegram/TgWsProxySettingsActivity.java @@ -103,6 +103,21 @@ public void onItemClick(int id) { LayoutHelper.MATCH_PARENT, LayoutHelper.WRAP_CONTENT )); + addShadow(context, content); + addHeader(context, content, tr(context, "ОБНОВЛЕНИЯ")); + + TextSettingsCell checkUpdates = actionCell(context, tr(context, "Проверить обновления Telegram-WSP")); + checkUpdates.setOnClickListener(v -> TgWsProxyController.checkForUpdates(context)); + content.addView(checkUpdates, LayoutHelper.createLinear( + LayoutHelper.MATCH_PARENT, LayoutHelper.WRAP_CONTENT + )); + + TextSettingsCell releases = actionCell(context, tr(context, "Открыть релизы Telegram-WSP")); + releases.setOnClickListener(v -> TgWsProxyController.openReleases(context)); + content.addView(releases, LayoutHelper.createLinear( + LayoutHelper.MATCH_PARENT, LayoutHelper.WRAP_CONTENT + )); + addShadow(context, content); addHeader(context, content, tr(context, "МАРШРУТИЗАЦИЯ")); @@ -768,6 +783,9 @@ private static String tr(Context context, String key) { private static String trEn(String key) { switch (key) { + case "ОБНОВЛЕНИЯ": return "UPDATES"; + case "Проверить обновления Telegram-WSP": return "Check for Telegram-WSP updates"; + case "Открыть релизы Telegram-WSP": return "Open Telegram-WSP releases"; case "МАРШРУТИЗАЦИЯ": return "ROUTING"; case "Мобильная сеть": return "Mobile network"; case "AWG и Worker используются только когда настроены и доступны.": return "AWG and Worker are used only when configured and available."; diff --git a/scripts/apply-integration.ps1 b/scripts/apply-integration.ps1 index a57bbf8..a2edb93 100644 --- a/scripts/apply-integration.ps1 +++ b/scripts/apply-integration.ps1 @@ -203,14 +203,22 @@ if ($coreBuild -notmatch [regex]::Escape('tgwsproxy-core.aar')) { $coreBuild = $coreBuild.Replace($coreDependencyMarker, $coreDependencyBlock) } -$apiGradleMarker = @' - defaultConfig { - minSdkVersion 21 - targetSdkVersion 36 -'@.TrimEnd() +# Both confirmed Telegram baselines are supported; unknown SDK contracts fail closed. +# Telegram 12.10.6 requires API 21, while Telegram 13.0.3 requires API 23. +$apiGradlePattern = '(?m)^ defaultConfig \{\r?\n minSdkVersion (21|23)\r?\n targetSdkVersion 36' +$coreSdkMatches = [regex]::Matches($coreBuild, $apiGradlePattern) +$appSdkMatches = [regex]::Matches($build, $apiGradlePattern) +if ($coreSdkMatches.Count -ne 1 -or $appSdkMatches.Count -ne 1) { + throw "Telegram minSdk/targetSdk contract is unsupported: core=$($coreSdkMatches.Count), standalone=$($appSdkMatches.Count)." +} +$minimumSdk = [int]$coreSdkMatches[0].Groups[1].Value +if ($minimumSdk -ne [int]$appSdkMatches[0].Groups[1].Value) { + throw 'Telegram core/standalone minSdk versions differ.' +} +$apiGradleMarker = $coreSdkMatches[0].Value $apiGradleBlock = @' defaultConfig { - minSdkVersion 21 + minSdkVersion __MIN_SDK__ targetSdkVersion 36 def telegramApiId = System.getenv('TELEGRAM_API_ID') ?: getProps('TELEGRAM_API_ID') @@ -230,6 +238,7 @@ $apiGradleBlock = @' buildConfigField "int", "TELEGRAM_API_ID", telegramApiId buildConfigField "String", "TELEGRAM_API_HASH", "\"${telegramApiHash}\"" '@.TrimEnd() +$apiGradleBlock = $apiGradleBlock.Replace('__MIN_SDK__', [string]$minimumSdk) if ($coreBuild -notmatch [regex]::Escape('TELEGRAM_API_ID')) { $count = ([regex]::Matches($coreBuild, [regex]::Escape($apiGradleMarker))).Count if ($count -ne 1) { throw "Telegram API Gradle anchor count is $count; expected 1." } diff --git a/scripts/build-release.ps1 b/scripts/build-release.ps1 index a939480..42aeefb 100644 --- a/scripts/build-release.ps1 +++ b/scripts/build-release.ps1 @@ -286,6 +286,14 @@ try { throw "Release APK versionCode does not match WSP revision. Expected $expectedVersionCode." } + # The produced APK must not silently lower the pinned Telegram SDK floor. + $standaloneGradle = Get-Content (Join-Path $telegramPath 'TMessagesProj_AppStandalone/build.gradle') -Raw + $sdkMatches = [regex]::Matches($standaloneGradle, '(?m)^[ \t]*minSdkVersion[ \t]+(\d+)[ \t]*\r?\n') + if ($sdkMatches.Count -ne 1) { throw 'Ambiguous standalone minSdkVersion.' } + $minimumSdk = [int]$sdkMatches[0].Groups[1].Value + if ($badgingText -notmatch ("sdkVersion:'" + $minimumSdk + "'")) { + throw "APK minimum SDK does not match upstream: expected API $minimumSdk." + } Write-Host 'APK metadata:' $badgingLines | Select-String -Pattern 'package:|application-label:|application-icon' | diff --git a/scripts/ci.ps1 b/scripts/ci.ps1 index c74b8df..c0b1340 100644 --- a/scripts/ci.ps1 +++ b/scripts/ci.ps1 @@ -33,6 +33,8 @@ $required = @( 'scripts/build-release.ps1', 'scripts/create-release-keystore.ps1', 'scripts/sync-upstream.ps1', + 'scripts/resolve-upstream-release.ps1', + 'scripts/test-upstream-release-decision.ps1', 'scripts/package-release-source.ps1', 'scripts/bootstrap-release-actions.ps1', 'scripts/diagnose-xiaomi-dark-mode.ps1', @@ -68,6 +70,8 @@ foreach ($scriptFile in $powerShellScripts) { } } +& (Join-Path $root 'scripts/test-upstream-release-decision.ps1') + $diagnosticScript = Get-Content (Join-Path $root 'scripts/diagnose-xiaomi-dark-mode.ps1') -Raw if ($diagnosticScript -match '\[string\[\]\]\$Args') { throw 'diagnose-xiaomi-dark-mode.ps1 must not shadow the PowerShell automatic $Args variable.' @@ -208,6 +212,10 @@ if ($releaseWorkflow -notmatch 'RELEASE_KEYSTORE_BASE64' -or $releaseWorkflow -n if ($releaseWorkflow -notmatch 'latest\.json' -or $releaseWorkflow -notmatch 'package-release-source\.ps1') { throw 'Release workflow must publish the update feed and Corresponding Source assets.' } +if ($releaseWorkflow -match 'gh release upload|gh release edit.*--notes-file' -or + $releaseWorkflow -notmatch 'already exists; refusing to overwrite signed assets') { + throw 'Published stable release assets must never be overwritten on retry.' +} if ($releaseWorkflow -match '(?m)^\s*\$home\s*=') { throw 'Release workflow must not assign to PowerShell automatic HOME on Windows runners.' @@ -228,32 +236,25 @@ if ($upstreamWorkflow -notmatch 'schedule:' -or $upstreamWorkflow -notmatch 'syn throw 'Upstream workflow must check Telegram on a schedule and call the reusable release workflow.' } -if ($upstreamWorkflow -notmatch '\$required\s*=\s*\$forced\s+-or\s+\(\$changed\s+-and\s+-not\s+\$releaseExists\)') { - throw 'Upstream release decision must require an upstream change unless force_release is explicit.' -} -if ($upstreamWorkflow -match '\$changed\s+-or\s+\$forced\s+-or\s+-not\s+\$releaseExists') { - throw 'Upstream release decision must not publish merely because the default revision tag is missing.' -} -if (-not $upstreamWorkflow.Contains('gh release list') -or - -not $upstreamWorkflow.Contains('isDraft,isPrerelease') -or - -not $upstreamWorkflow.Contains('Measure-Object -Maximum') -or - -not $upstreamWorkflow.Contains("tagName -match '^v.+-wsp\.(\d+)$'")) { - throw 'Upstream sync must inherit the highest stable WSP revision when no explicit revision is requested.' +if ($upstreamWorkflow -notmatch 'resolve-upstream-release\.ps1' -or + $upstreamWorkflow -notmatch 'gh release list' -or + $upstreamWorkflow -notmatch 'isDraft,isPrerelease' -or + $upstreamWorkflow -notmatch 'release_required=' -or + $upstreamWorkflow -notmatch 'release_ref=' -or + $upstreamWorkflow -notmatch 'revision=') { + throw 'Upstream release decision must use the tested, revision-aware stable resolver.' } -if (-not $upstreamWorkflow.Contains("default: ''")) { - throw 'Upstream sync revision override must default to empty so scheduled runs can inherit the stable WSP revision.' +if ($upstreamWorkflow -notmatch "default: ''") { + throw 'Upstream sync revision override must default to empty so scheduled runs inherit stable WSP revision.' } - -if ($upstreamWorkflow -notmatch 'Validate release secrets before changing main' -or $upstreamWorkflow -notmatch 'RELEASE_KEYSTORE_BASE64') { - throw 'Upstream workflow must fail before changing main when release secrets are not configured.' +if ($upstreamWorkflow -notmatch 'Validate release secrets before changing main' -or + $upstreamWorkflow -notmatch 'RELEASE_KEYSTORE_BASE64') { + throw 'Upstream workflow must validate release secrets before committing the pin.' } - -if (-not $upstreamWorkflow.Contains('$releaseExists = $?')) { - throw 'Upstream release decision must capture whether the GitHub Release exists.' -} -if (-not $upstreamWorkflow.Contains('exit 0')) { - throw 'Upstream release decision must clear the expected missing-release native exit code.' +if ($upstreamWorkflow -notmatch 'Compile candidate APK before changing main' -or + $upstreamWorkflow -notmatch 'build-apk\.ps1 -SkipPrepare') { + throw 'A changed upstream must compile before its pin is pushed to main.' } $releaseBootstrapScript = Get-Content (Join-Path $root 'scripts/bootstrap-release-actions.ps1') -Raw @@ -353,6 +354,34 @@ if ($applyScript -notmatch 'TELEGRAM_WSP_RELEASE_REVISION' -or $applyScript -not throw 'Integration script must encode the Telegram-WSP release revision into Android versionCode.' } +$updater = Get-Content (Join-Path $root 'integration/telegram/TgWsProxyBootstrap.java') -Raw +$settings = Get-Content (Join-Path $root 'integration/telegram/TgWsProxySettingsActivity.java') -Raw +$controller = Get-Content (Join-Path $root 'integration/telegram/TgWsProxyController.java') -Raw +if ($updater -match 'KEY_UPDATE_LAST_CHECK' -or + $updater -notmatch 'KEY_UPDATE_LAST_SUCCESS' -or + $updater -notmatch 'KEY_UPDATE_LAST_ATTEMPT' -or + $updater -notmatch 'UPDATE_RETRY_DELAY_MS') { + throw 'Update check must throttle successful checks and failed attempts separately.' +} +if ($updater -notmatch 'onActivityResumed' -or + $updater -notmatch 'KEY_UPDATE_PENDING_MANIFEST' -or + $settings -notmatch 'TgWsProxyController\.checkForUpdates' -or + $settings -notmatch 'TgWsProxyController\.openReleases' -or + $controller -notmatch 'registerUpdateActions' -or + $updater -notmatch 'TgWsProxyController\.registerUpdateActions') { + throw 'Updater must support foreground checks, pending offers and manual/browser actions.' +} +if ($updater -notmatch 'validateUpdate\(JSONObject' -or + $updater -notmatch 'hasSameSigningCertificate' -or + $updater -notmatch 'getPackageArchiveInfo' -or + $updater -notmatch 'version > getCurrentVersionCode' -or + $updater -notmatch 'release-assets\.githubusercontent\.com') { + throw 'Updater must validate stable metadata, new APK version and signing/redirect boundaries.' +} +if ($releaseWorkflow -notmatch 'minimumSdk = \$sdkVersions\[0\]') { + throw 'Update feed must describe the actual Telegram minimum SDK.' +} + $releaseBuildScript = Get-Content (Join-Path $root 'scripts/build-release.ps1') -Raw if ($releaseBuildScript -notmatch 'build-apk\.ps1' -or $releaseBuildScript -notmatch 'Full\s*=\s*\$true') { throw 'Release build script must delegate to the full afatStandalone build.' diff --git a/scripts/resolve-upstream-release.ps1 b/scripts/resolve-upstream-release.ps1 new file mode 100644 index 0000000..7a261bc --- /dev/null +++ b/scripts/resolve-upstream-release.ps1 @@ -0,0 +1,102 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)][string]$TelegramVersion, + [Parameter(Mandatory = $true)][bool]$UpstreamChanged, + [Parameter(Mandatory = $true)][bool]$ForceRelease, + [Parameter(Mandatory = $true)][string]$ReleaseListJson, + [Parameter(Mandatory = $true)][string]$ReleaseRef, + [string]$RequestedRevision = '' +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +if ($TelegramVersion -notmatch '^\d+\.\d+\.\d+$') { + throw "Unsupported Telegram version: $TelegramVersion" +} +$targetVersion = [version]$TelegramVersion + +$releases = @(ConvertFrom-Json -InputObject $ReleaseListJson) +$highestStableVersion = $null +$highestStableRevision = 0 +$highestCurrentRevision = 0 +$currentVersionPublished = $false +$knownTags = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::Ordinal) + +foreach ($release in $releases) { + if ($null -eq $release.tagName -or [string]::IsNullOrWhiteSpace([string]$release.tagName)) { + throw 'GitHub release list contains a missing tagName.' + } + [void]$knownTags.Add([string]$release.tagName) + if ($release.isDraft -or $release.isPrerelease) { continue } + if ($release.tagName -notmatch '^v(\d+\.\d+\.\d+)-wsp\.(\d+)$') { continue } + + $version = [version]$Matches[1] + $revision = [int]$Matches[2] + if ($revision -lt 1 -or $revision -gt 99) { + throw "Stable Telegram-WSP release revision is invalid: $($release.tagName)" + } + if ($null -eq $highestStableVersion -or $version -gt $highestStableVersion) { + $highestStableVersion = $version + } + if ($revision -gt $highestStableRevision) { + $highestStableRevision = $revision + } + if ($version -eq $targetVersion) { + $currentVersionPublished = $true + if ($revision -gt $highestCurrentRevision) { + $highestCurrentRevision = $revision + } + } +} + +if ($null -ne $highestStableVersion -and $targetVersion -lt $highestStableVersion) { + throw "Pinned Telegram $TelegramVersion is older than published stable Telegram $highestStableVersion." +} +if ($RequestedRevision -ne '' -and $RequestedRevision -notmatch '^\d+$') { + throw 'Requested Telegram-WSP revision must be decimal digits.' +} +$revision = if ($RequestedRevision -eq '') { + [Math]::Max(1, $highestStableRevision) +} else { + [int]$RequestedRevision +} +if ($revision -lt 1 -or $revision -gt 99) { + throw 'Requested Telegram-WSP revision must be between 1 and 99.' +} + +$tag = "v$TelegramVersion-wsp.$revision" +if ($currentVersionPublished -and $revision -lt $highestCurrentRevision -and + -not $knownTags.Contains($tag)) { + throw "Refusing to publish lower WSP revision $tag after revision $highestCurrentRevision." +} + +$tagExists = $knownTags.Contains($tag) +$missingStableForPin = -not $currentVersionPublished +# A failed Windows signing/publishing job can leave the validated upstream pin +# on main. Retry the missing release on the next scheduled run without requiring +# the Telegram master commit to change again. +$releaseRequired = -not $tagExists -and ( + $ForceRelease -or + ($missingStableForPin -and ($UpstreamChanged -or + $null -eq $highestStableVersion -or $targetVersion -gt $highestStableVersion)) +) +$reason = if ($tagExists) { + 'tag-already-exists' +} elseif ($releaseRequired -and $UpstreamChanged) { + 'new-upstream' +} elseif ($releaseRequired -and $ForceRelease) { + 'explicit-force' +} elseif ($releaseRequired) { + 'retry-unpublished-upstream' +} else { + 'stable-current' +} + +[ordered]@{ + releaseRequired = [bool]$releaseRequired + releaseRef = $ReleaseRef + revision = $revision + tag = $tag + reason = $reason +} | ConvertTo-Json -Compress diff --git a/scripts/test-upstream-release-decision.ps1 b/scripts/test-upstream-release-decision.ps1 new file mode 100644 index 0000000..d01ef2b --- /dev/null +++ b/scripts/test-upstream-release-decision.ps1 @@ -0,0 +1,79 @@ +[CmdletBinding()] +param() + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +$scriptPath = Join-Path $PSScriptRoot 'resolve-upstream-release.ps1' + +function Test-Decision { + param( + [string]$Name, + [string]$Version, + [bool]$Changed, + [bool]$Force, + [object[]]$Releases, + [string]$Revision = '', + [bool]$ExpectedRequired, + [string]$ExpectedTag, + [string]$ExpectedReason = '' + ) + + $releaseList = ConvertTo-Json -InputObject @($Releases) -Compress -Depth 4 + $rawResult = & $scriptPath -TelegramVersion $Version -UpstreamChanged $Changed ` + -ForceRelease $Force -ReleaseListJson $releaseList -ReleaseRef 'regression-ref' ` + -RequestedRevision $Revision + $result = $rawResult | ConvertFrom-Json + if ([bool]$result.releaseRequired -ne $ExpectedRequired -or $result.tag -ne $ExpectedTag) { + throw "$Name failed: $(ConvertTo-Json $result -Compress)" + } + if ($ExpectedReason -ne '' -and $result.reason -ne $ExpectedReason) { + throw "$($Name): unexpected reason $($result.reason)" + } + if ($result.releaseRef -ne 'regression-ref') { + throw "$($Name): release ref was not preserved." + } + Write-Host "PASS: $Name" +} + +$stableOld = @{ tagName = 'v12.10.6-wsp.2'; isDraft = $false; isPrerelease = $false } +$stableOldFirst = @{ tagName = 'v12.10.6-wsp.1'; isDraft = $false; isPrerelease = $false } +$stableNew = @{ tagName = 'v13.0.3-wsp.2'; isDraft = $false; isPrerelease = $false } +$stableNewFirst = @{ tagName = 'v13.0.3-wsp.1'; isDraft = $false; isPrerelease = $false } +$draftNew = @{ tagName = 'v13.0.3-wsp.2'; isDraft = $true; isPrerelease = $false } +$prereleaseNew = @{ tagName = 'v13.0.3-wsp.2'; isDraft = $false; isPrerelease = $true } + +Test-Decision -Name 'First publication' -Version '12.10.6' -Changed $true -Force $false ` + -Releases @() -ExpectedRequired $true -ExpectedTag 'v12.10.6-wsp.1' +Test-Decision -Name 'Pinned stable already published' -Version '12.10.6' -Changed $false -Force $false ` + -Releases @($stableOld, $stableOldFirst) -ExpectedRequired $false -ExpectedTag 'v12.10.6-wsp.2' +Test-Decision -Name 'New upstream inherits global WSP revision' -Version '13.0.3' -Changed $true -Force $false ` + -Releases @($stableOld, $stableOldFirst) -ExpectedRequired $true -ExpectedTag 'v13.0.3-wsp.2' +Test-Decision -Name 'Retry missing release after upstream pin commit' -Version '13.0.3' -Changed $false -Force $false ` + -Releases @($stableOld, $stableOldFirst) -ExpectedRequired $true -ExpectedTag 'v13.0.3-wsp.2' ` + -ExpectedReason 'retry-unpublished-upstream' +Test-Decision -Name 'Same version stable already published' -Version '13.0.3' -Changed $false -Force $false ` + -Releases @($stableOld, $stableNew) -ExpectedRequired $false -ExpectedTag 'v13.0.3-wsp.2' +Test-Decision -Name 'Do not invent missing WSP revision hotfix' -Version '13.0.3' -Changed $false -Force $false ` + -Releases @($stableOld, $stableNewFirst) -ExpectedRequired $false -ExpectedTag 'v13.0.3-wsp.2' +Test-Decision -Name 'Explicit newer hotfix revision' -Version '13.0.3' -Changed $false -Force $true ` + -Releases @($stableOld, $stableNewFirst) -Revision '3' -ExpectedRequired $true -ExpectedTag 'v13.0.3-wsp.3' +Test-Decision -Name 'Never overwrite existing stable tag even if forced' -Version '13.0.3' -Changed $false -Force $true ` + -Releases @($stableOld, $stableNew) -ExpectedRequired $false -ExpectedTag 'v13.0.3-wsp.2' +Test-Decision -Name 'Never overwrite an existing draft tag' -Version '13.0.3' -Changed $true -Force $false ` + -Releases @($stableOld, $draftNew) -ExpectedRequired $false -ExpectedTag 'v13.0.3-wsp.2' +Test-Decision -Name 'Never overwrite an existing prerelease tag' -Version '13.0.3' -Changed $true -Force $false ` + -Releases @($stableOld, $prereleaseNew) -ExpectedRequired $false -ExpectedTag 'v13.0.3-wsp.2' + +$rollbackRejected = $false +try { + & $scriptPath -TelegramVersion '12.10.5' -UpstreamChanged $true -ForceRelease $true ` + -ReleaseListJson (ConvertTo-Json -InputObject @($stableOld) -Compress) -ReleaseRef 'test' | Out-Null +} catch { + if ($_.Exception.Message -notmatch 'older than published stable') { throw } + $rollbackRejected = $true +} +if (-not $rollbackRejected) { throw 'Release rollback was not rejected.' } +Write-Host 'PASS: version rollback rejected' + +Write-Host 'Release decision regression tests passed.'