From 9bbed0654a6755ba7fc513c405a5ef11f91de492 Mon Sep 17 00:00:00 2001 From: Tom Riglar Date: Mon, 26 Oct 2026 11:44:29 +0000 Subject: [PATCH] feat(upload): record APK CPU architectures in binary metadata Co-Authored-By: Claude Opus 5.5 --- src/services/metadata-extractor.service.ts | 44 +++++++++++++++++- src/types.ts | 11 +++++ test/unit/metadata-extractor.service.test.ts | 49 ++++++++++++++++++++ 3 files changed, 103 insertions(+), 1 deletion(-) diff --git a/src/services/metadata-extractor.service.ts b/src/services/metadata-extractor.service.ts index 942ca0b..533c466 100644 --- a/src/services/metadata-extractor.service.ts +++ b/src/services/metadata-extractor.service.ts @@ -6,8 +6,14 @@ import { parse } from 'plist'; import { readManifestPackage } from '../utils/android-manifest.js'; +export type CpuArchitecture = 'arm' | 'x86'; + export interface TAppMetadata { + /** APK only: the native ABIs shipped under `lib//`, sorted. */ + abis?: string[]; appId: string; + /** APK only: the CPU families the APK can run on. See `apkArchitectures`. */ + architectures?: CpuArchitecture[]; platform: 'android' | 'ios'; } @@ -38,6 +44,36 @@ function parseInfoPlist(buffer: Buffer): { CFBundleIdentifier: string } { return data; } +const ABI_ARCHITECTURE: Record = { + armeabi: 'arm', + 'armeabi-v7a': 'arm', + 'arm64-v8a': 'arm', + x86: 'x86', + x86_64: 'x86', +}; + +/** The native ABIs in an APK: each `lib//` directory that holds a file. */ +export function apkAbis(entryNames: string[]): string[] { + const abis = new Set(); + for (const name of entryNames) { + const match = /^lib\/([^/]+)\/[^/]+$/.exec(name); + if (match) abis.add(match[1]); + } + + return [...abis].sort(); +} + +/** + * The CPU families an APK can run on, from its native ABIs. An APK with no + * native code runs on both. ABIs outside arm and x86 (mips, riscv64) are + * ignored, so an APK that ships only those gets an empty list. + */ +export function apkArchitectures(abis: string[]): CpuArchitecture[] { + if (abis.length === 0) return ['arm', 'x86']; + const found = new Set(abis.map((abi) => ABI_ARCHITECTURE[abi]).filter(Boolean)); + return (['arm', 'x86'] as const).filter((arch) => found.has(arch)); +} + /** * Extracts metadata from Android APK files */ @@ -50,7 +86,13 @@ export class AndroidMetadataExtractor implements IMetadataExtractor { const zip = new StreamZip.async({ file: filePath }); try { const manifest = await zip.entryData('AndroidManifest.xml'); - return { appId: readManifestPackage(manifest), platform: 'android' }; + const abis = apkAbis(Object.keys(await zip.entries())); + return { + abis, + appId: readManifestPackage(manifest), + architectures: apkArchitectures(abis), + platform: 'android', + }; } finally { await zip.close().catch(() => {}); } diff --git a/src/types.ts b/src/types.ts index a3f1a49..96ea274 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,7 +1,18 @@ +import type { CpuArchitecture } from './services/metadata-extractor.service.js'; import type { BinaryEnvelope } from './utils/envelope.js'; export type TAppMetadata = { + /** + * APK only: the native ABIs under `lib//` (e.g. `arm64-v8a`, `x86_64`). + * Stored on `binaries.metadata.abis`. Empty when the APK has no native code. + */ + abis?: string[]; appId: string; + /** + * APK only: the CPU families the APK can run on, `arm`, `x86` or both. + * Stored on `binaries.metadata.architectures`. Both when it has no native code. + */ + architectures?: CpuArchitecture[]; platform: 'android' | 'ios'; /** * Present when the binary was client-side envelope-encrypted before upload diff --git a/test/unit/metadata-extractor.service.test.ts b/test/unit/metadata-extractor.service.test.ts index 0d20454..7978632 100644 --- a/test/unit/metadata-extractor.service.test.ts +++ b/test/unit/metadata-extractor.service.test.ts @@ -5,6 +5,8 @@ import * as path from 'node:path'; import { AndroidMetadataExtractor, + apkAbis, + apkArchitectures, MetadataExtractorService, } from '../../src/services/metadata-extractor.service.js'; @@ -22,7 +24,9 @@ describe('AndroidMetadataExtractor', () => { it('extracts the package id from a real APK', async () => { const result = await extractor.extract(WIKIPEDIA_APK); expect(result).to.deep.equal({ + abis: ['arm64-v8a', 'armeabi-v7a', 'x86', 'x86_64'], appId: 'org.wikipedia', + architectures: ['arm', 'x86'], platform: 'android', }); }); @@ -45,13 +49,58 @@ describe('AndroidMetadataExtractor', () => { }); }); +describe('apkAbis', () => { + it('lists each lib// directory that holds a file, sorted', () => { + expect( + apkAbis([ + 'AndroidManifest.xml', + 'lib/x86_64/libapp.so', + 'lib/arm64-v8a/libapp.so', + 'lib/arm64-v8a/libother.so', + 'lib/x86/', + 'lib/arm64-v8a/nested/libdeep.so', + 'assets/lib/x86/fake.so', + ]), + ).to.deep.equal(['arm64-v8a', 'x86_64']); + }); + + it('is empty for an APK with no native code', () => { + expect(apkAbis(['AndroidManifest.xml', 'classes.dex'])).to.deep.equal([]); + }); +}); + +describe('apkArchitectures', () => { + it('is arm only for arm ABIs', () => { + expect(apkArchitectures(['arm64-v8a', 'armeabi-v7a'])).to.deep.equal(['arm']); + }); + + it('is x86 only for x86 ABIs', () => { + expect(apkArchitectures(['x86', 'x86_64'])).to.deep.equal(['x86']); + }); + + it('is both for a fat APK', () => { + expect(apkArchitectures(['arm64-v8a', 'x86_64'])).to.deep.equal(['arm', 'x86']); + }); + + it('is both when there is no native code', () => { + expect(apkArchitectures([])).to.deep.equal(['arm', 'x86']); + }); + + it('ignores ABIs outside arm and x86', () => { + expect(apkArchitectures(['riscv64'])).to.deep.equal([]); + expect(apkArchitectures(['mips', 'arm64-v8a'])).to.deep.equal(['arm']); + }); +}); + describe('MetadataExtractorService (APK path)', () => { const service = new MetadataExtractorService(); it('returns android metadata for a real APK', async () => { const result = await service.extract(WIKIPEDIA_APK); expect(result).to.deep.equal({ + abis: ['arm64-v8a', 'armeabi-v7a', 'x86', 'x86_64'], appId: 'org.wikipedia', + architectures: ['arm', 'x86'], platform: 'android', }); });