Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 43 additions & 1 deletion src/services/metadata-extractor.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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/<abi>/`, sorted. */
abis?: string[];
appId: string;
/** APK only: the CPU families the APK can run on. See `apkArchitectures`. */
architectures?: CpuArchitecture[];
platform: 'android' | 'ios';
}

Expand Down Expand Up @@ -38,6 +44,36 @@ function parseInfoPlist(buffer: Buffer): { CFBundleIdentifier: string } {
return data;
}

const ABI_ARCHITECTURE: Record<string, CpuArchitecture> = {
armeabi: 'arm',
'armeabi-v7a': 'arm',
'arm64-v8a': 'arm',
x86: 'x86',
x86_64: 'x86',
};

/** The native ABIs in an APK: each `lib/<abi>/` directory that holds a file. */
export function apkAbis(entryNames: string[]): string[] {
const abis = new Set<string>();
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
*/
Expand All @@ -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(() => {});
}
Expand Down
11 changes: 11 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
@@ -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/<abi>/` (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
Expand Down
49 changes: 49 additions & 0 deletions test/unit/metadata-extractor.service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import * as path from 'node:path';

import {
AndroidMetadataExtractor,
apkAbis,
apkArchitectures,
MetadataExtractorService,
} from '../../src/services/metadata-extractor.service.js';

Expand All @@ -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',
});
});
Expand All @@ -45,13 +49,58 @@ describe('AndroidMetadataExtractor', () => {
});
});

describe('apkAbis', () => {
it('lists each lib/<abi>/ 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',
});
});
Expand Down
Loading