41 lines
3 KiB
TypeScript
41 lines
3 KiB
TypeScript
// Google Play internal app sharing: upload URL, limits and the response. Pure, used by cli/play/share.ts.
|
|
// Sources (checked October 2026):
|
|
// internalappsharingartifacts.uploadbundle / uploadapk:
|
|
// https://developers.google.com/android-publisher/api-ref/rest/v3/internalappsharingartifacts
|
|
// POST https://androidpublisher.googleapis.com/upload/androidpublisher/v3/applications/internalappsharing/{packageName}/artifacts/bundle (and /apk)
|
|
// Not under applications/{packageName} like the edit calls. Scope https://www.googleapis.com/auth/androidpublisher.
|
|
// Response InternalAppSharingArtifact { downloadUrl, certificateFingerprint, sha256 (lowercase hex, as sha256sum) }.
|
|
// Discovery document (androidpublisher v3): mediaUpload.maxSize bundle 10737418240 (10 GiB), apk 1073741824 (1 GiB);
|
|
// accept application/octet-stream (apk also application/vnd.android.package-archive).
|
|
// Internal app sharing (Play Console Help, https://support.google.com/googleplay/android-developer/answer/9844679):
|
|
// uploaders need "Release apps to testing tracks" or to be on the uploader list; testers turn on internal app
|
|
// sharing in the Play Store app (Settings > About > tap the Play Store version 7 times > Internal app sharing);
|
|
// links expire 60 days after the upload; up to 100 users per link; Google re-signs with an internal sharing key.
|
|
export type ShareKind = 'bundle' | 'apk';
|
|
|
|
export const SHARE_LIMIT: Record<ShareKind, number> = { bundle: 10737418240, apk: 1073741824 };
|
|
|
|
export const shareUrl = (packageName: string, kind: ShareKind): string =>
|
|
`https://androidpublisher.googleapis.com/upload/androidpublisher/v3/applications/internalappsharing/${encodeURIComponent(packageName)}/artifacts/${kind}?uploadType=media`;
|
|
|
|
export const SHARE_HEADERS = { 'Content-Type': 'application/octet-stream' } as const;
|
|
|
|
export interface ShareArtifact { downloadUrl: string; certificateFingerprint: string; sha256: string }
|
|
|
|
export function parseShareArtifact(r: unknown): ShareArtifact {
|
|
const o = (r ?? {}) as Record<string, unknown>;
|
|
if (typeof o.downloadUrl !== 'string' || !o.downloadUrl.startsWith('https://')) throw new Error('internal app sharing: the answer has no downloadUrl');
|
|
return { downloadUrl: o.downloadUrl, certificateFingerprint: String(o.certificateFingerprint ?? ''), sha256: String(o.sha256 ?? '').toLowerCase() };
|
|
}
|
|
|
|
/** Checks the file kind against the flag and the size against the API limit. Returns problems (empty = fine). */
|
|
export function shareProblems(kind: ShareKind, file: string, bytes: number): string[] {
|
|
const e: string[] = [];
|
|
if (kind === 'bundle' && !/\.aab$/i.test(file)) e.push(`--aab ${file}: expected an .aab file`);
|
|
if (kind === 'apk' && !/\.apk$/i.test(file)) e.push(`--apk ${file}: expected an .apk file`);
|
|
if (bytes <= 0) e.push(`${file} is empty`);
|
|
if (bytes > SHARE_LIMIT[kind]) e.push(`${file} is ${mb(bytes)}, above the ${mb(SHARE_LIMIT[kind])} internal app sharing limit for ${kind === 'bundle' ? 'app bundles' : 'APKs'}`);
|
|
return e;
|
|
}
|
|
|
|
export const mb = (b: number): string => `${(b / 1048576).toFixed(1)} MB`;
|