From a9e99a28eac7469201b39abe85601d89a310df49 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Tue, 14 Jul 2026 09:11:15 +0300 Subject: [PATCH] fix: resolve Android APK download URLs for mobile updates Prefers a real APK asset when the update API points to an AAB Keeps direct APK links from the API unchanged Uses the GitHub releases API as a fallback for Android mobile downloads --- packages/web/server/lib/package-manager.js | 49 ++++++++++++++- .../web/server/lib/package-manager.test.js | 60 +++++++++++++++++++ 2 files changed, 106 insertions(+), 3 deletions(-) diff --git a/packages/web/server/lib/package-manager.js b/packages/web/server/lib/package-manager.js index 9c2cd38b..153a84cc 100644 --- a/packages/web/server/lib/package-manager.js +++ b/packages/web/server/lib/package-manager.js @@ -13,6 +13,7 @@ const PACKAGE_PATH_SEGMENTS = PACKAGE_NAME.split('/'); const NPM_REGISTRY_URL = `https://registry.npmjs.org/${PACKAGE_NAME}`; const CHANGELOG_URL = 'https://raw.githubusercontent.com/openchamber/openchamber/main/CHANGELOG.md'; const GITHUB_RELEASES_URL = 'https://github.com/openchamber/openchamber/releases'; +const GITHUB_RELEASES_API_URL = 'https://api.github.com/repos/openchamber/openchamber/releases'; let cachedDetectedPm = null; function getSpawnSyncBaseOptions() { @@ -85,6 +86,40 @@ function normalizeArch(value) { return mapArch(process.arch); } +async function resolveAndroidApkUrl(version, candidateUrl) { + if (typeof candidateUrl === 'string') { + try { + if (new URL(candidateUrl).pathname.toLowerCase().endsWith('.apk')) return candidateUrl; + } catch { + // Resolve malformed or non-APK values from the authoritative release assets below. + } + } + + try { + const response = await fetch(`${GITHUB_RELEASES_API_URL}/tags/v${version}`, { + headers: { + Accept: 'application/vnd.github+json', + 'User-Agent': 'openchamber-update-check', + }, + signal: AbortSignal.timeout(10000), + }); + if (!response.ok) return undefined; + + const release = await response.json(); + const apkAssets = Array.isArray(release?.assets) + ? release.assets.filter((asset) => ( + typeof asset?.name === 'string' + && asset.name.toLowerCase().endsWith('.apk') + && typeof asset.browser_download_url === 'string' + )) + : []; + const canonicalAsset = apkAssets.find((asset) => /^OpenChamber-.+-android\.apk$/i.test(asset.name)); + return (canonicalAsset || apkAssets[0])?.browser_download_url; + } catch { + return undefined; + } +} + async function checkForUpdatesFromApi(currentVersion, options = {}) { try { const appType = normalizeAppType(options.appType); @@ -128,13 +163,17 @@ async function checkForUpdatesFromApi(currentVersion, options = {}) { : typeof data.download?.url === 'string' ? data.download.url : undefined; + const updateAvailable = Boolean(data.updateAvailable) && versionComparison > 0; + const mobileDownloadUrl = updateAvailable && appType === 'mobile-capacitor' && platform === 'android' + ? await resolveAndroidApkUrl(data.latestVersion, downloadUrl) + : undefined; return { - available: Boolean(data.updateAvailable) && versionComparison > 0, + available: updateAvailable, version: data.latestVersion, currentVersion, body: typeof data.releaseNotes === 'string' ? data.releaseNotes : undefined, releaseUrl: typeof data.releaseNotesUrl === 'string' ? data.releaseNotesUrl : releaseUrl, - downloadUrl: appType === 'mobile-capacitor' ? (downloadUrl || releaseUrl) : undefined, + downloadUrl: mobileDownloadUrl, nextSuggestedCheckInSec: typeof data.nextSuggestedCheckInSec === 'number' && Number.isFinite(data.nextSuggestedCheckInSec) ? data.nextSuggestedCheckInSec @@ -762,8 +801,12 @@ export async function checkForUpdates(options = {}) { const available = compareVersions(latestVersion, currentVersion) > 0; let changelog; + let downloadUrl; if (available) { changelog = await fetchChangelogNotes(currentVersion, latestVersion); + if (appType === 'mobile-capacitor' && platform === 'android') { + downloadUrl = await resolveAndroidApkUrl(latestVersion); + } } return { @@ -772,7 +815,7 @@ export async function checkForUpdates(options = {}) { currentVersion, body: changelog, releaseUrl: `${GITHUB_RELEASES_URL}/tag/v${latestVersion}`, - downloadUrl: appType === 'mobile-capacitor' && platform === 'android' ? `${GITHUB_RELEASES_URL}/tag/v${latestVersion}` : undefined, + downloadUrl, packageManager: pm, // Show our CLI command, not raw package manager command updateCommand: 'openchamber update', diff --git a/packages/web/server/lib/package-manager.test.js b/packages/web/server/lib/package-manager.test.js index a8b962e6..24867d28 100644 --- a/packages/web/server/lib/package-manager.test.js +++ b/packages/web/server/lib/package-manager.test.js @@ -141,6 +141,66 @@ describe('checkForUpdates', () => { expect(fetchMock).toHaveBeenCalledTimes(1); }); + it('resolves an Android APK asset when the update API returns an AAB', async () => { + fetchMock + .when('api.openchamber.dev', { + ok: true, + json: async () => ({ + latestVersion: '1.10.0', + updateAvailable: true, + downloadUrl: 'https://github.com/openchamber/openchamber/releases/download/v1.10.0/OpenChamber-1.10.0-42-android.aab', + }), + }) + .when('api.github.com/repos/openchamber/openchamber/releases/tags/v1.10.0', { + ok: true, + json: async () => ({ + assets: [ + { + name: 'OpenChamber-1.10.0-42-android.aab', + browser_download_url: 'https://downloads.example/OpenChamber-1.10.0-42-android.aab', + }, + { + name: 'app-release.apk', + browser_download_url: 'https://downloads.example/app-release.apk', + }, + { + name: 'OpenChamber-1.10.0-42-android.apk', + browser_download_url: 'https://downloads.example/OpenChamber-1.10.0-42-android.apk', + }, + ], + }), + }); + + const result = await checkForUpdates({ + appType: 'mobile-capacitor', + platform: 'android', + currentVersion: '1.9.10', + }); + + expect(result.downloadUrl).toBe('https://downloads.example/OpenChamber-1.10.0-42-android.apk'); + }); + + it('keeps a direct Android APK URL from the update API', async () => { + const apkUrl = 'https://github.com/openchamber/openchamber/releases/download/v1.10.0/OpenChamber-1.10.0-42-android.apk'; + fetchMock.when('api.openchamber.dev', { + ok: true, + json: async () => ({ + latestVersion: '1.10.0', + updateAvailable: true, + downloadUrl: apkUrl, + }), + }); + + const result = await checkForUpdates({ + appType: 'mobile-capacitor', + platform: 'android', + currentVersion: '1.9.10', + }); + + expect(result.downloadUrl).toBe(apkUrl); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + it('returns available=false when API claims update but npm is behind', async () => { fetchMock .when('api.openchamber.dev', {