// 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 = { 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; 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`;