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
This commit is contained in:
Bohdan Triapitsyn
2026-07-14 09:11:15 +03:00
parent 5a80638fba
commit a9e99a28ea
2 changed files with 106 additions and 3 deletions
+46 -3
View File
@@ -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',
@@ -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', {