@gpc-cli/core 0.10.4 → 0.10.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +35 -4
- package/dist/index.js +344 -86
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
package/dist/index.js
CHANGED
|
@@ -2315,28 +2315,18 @@ function reviewsToCsv(reviews) {
|
|
|
2315
2315
|
const uc = r.comments?.[0]?.userComment;
|
|
2316
2316
|
const fields = [
|
|
2317
2317
|
r.reviewId,
|
|
2318
|
-
|
|
2318
|
+
escapeCsvField(r.authorName),
|
|
2319
2319
|
uc?.starRating ?? "",
|
|
2320
|
-
|
|
2320
|
+
escapeCsvField(uc?.text ?? ""),
|
|
2321
2321
|
uc?.reviewerLanguage ?? "",
|
|
2322
2322
|
uc ? new Date(Number(uc.lastModified.seconds) * 1e3).toISOString() : "",
|
|
2323
|
-
|
|
2324
|
-
|
|
2323
|
+
escapeCsvField(uc?.device ?? ""),
|
|
2324
|
+
escapeCsvField(uc?.appVersionName ?? "")
|
|
2325
2325
|
];
|
|
2326
2326
|
return fields.join(",");
|
|
2327
2327
|
});
|
|
2328
2328
|
return [header, ...rows].join("\n");
|
|
2329
2329
|
}
|
|
2330
|
-
function csvEscape(value) {
|
|
2331
|
-
let safe = value;
|
|
2332
|
-
if (/^[=+\-@\t\r]/.test(safe)) {
|
|
2333
|
-
safe = `'${safe}`;
|
|
2334
|
-
}
|
|
2335
|
-
if (safe.includes(",") || safe.includes('"') || safe.includes("\n")) {
|
|
2336
|
-
return `"${safe.replace(/"/g, '""')}"`;
|
|
2337
|
-
}
|
|
2338
|
-
return safe;
|
|
2339
|
-
}
|
|
2340
2330
|
async function analyzeReviews2(client, packageName, options) {
|
|
2341
2331
|
const { reviews } = await listReviews(client, packageName, options);
|
|
2342
2332
|
const items = reviews.map((r) => {
|
|
@@ -4151,6 +4141,7 @@ function reportObjectNotFoundError(opts) {
|
|
|
4151
4141
|
var GCS_API_BASE = "https://storage.googleapis.com/storage/v1/b";
|
|
4152
4142
|
var LIST_TIMEOUT_MS = 6e4;
|
|
4153
4143
|
var DOWNLOAD_TIMEOUT_MS = 12e4;
|
|
4144
|
+
var MAX_DOWNLOAD_BYTES = 512 * 1024 * 1024;
|
|
4154
4145
|
function networkError(bucket, cause) {
|
|
4155
4146
|
const detail = cause instanceof Error ? cause.message : String(cause);
|
|
4156
4147
|
return new GpcError(
|
|
@@ -4160,6 +4151,14 @@ function networkError(bucket, cause) {
|
|
|
4160
4151
|
"Check your internet connection and retry. Google Cloud Storage may also be briefly unavailable."
|
|
4161
4152
|
);
|
|
4162
4153
|
}
|
|
4154
|
+
function tooLargeError(objectName, maxBytes) {
|
|
4155
|
+
return new GpcError(
|
|
4156
|
+
`Report object "${objectName}" is larger than the ${maxBytes}-byte download limit.`,
|
|
4157
|
+
"REPORT_DOWNLOAD_FAILED",
|
|
4158
|
+
4,
|
|
4159
|
+
"Play bulk reports are far smaller than this; confirm the bucket and object name, then download the file from Play Console -> Download reports if you really need it."
|
|
4160
|
+
);
|
|
4161
|
+
}
|
|
4163
4162
|
function maxRetries() {
|
|
4164
4163
|
const parsed = Number(process.env["GPC_MAX_RETRIES"]);
|
|
4165
4164
|
return Number.isInteger(parsed) && parsed >= 0 && parsed <= 10 ? parsed : 3;
|
|
@@ -4226,7 +4225,8 @@ async function listReportObjects(auth, bucket, options) {
|
|
|
4226
4225
|
}));
|
|
4227
4226
|
return { objects, nextPageToken: body.nextPageToken };
|
|
4228
4227
|
}
|
|
4229
|
-
async function downloadReportObject(auth, bucket, objectName) {
|
|
4228
|
+
async function downloadReportObject(auth, bucket, objectName, options = {}) {
|
|
4229
|
+
const maxBytes = options.maxBytes ?? MAX_DOWNLOAD_BYTES;
|
|
4230
4230
|
const url = `${GCS_API_BASE}/${encodeURIComponent(bucket)}/o/${encodeURIComponent(objectName)}?alt=media`;
|
|
4231
4231
|
const res = await gcsFetch(auth, bucket, url, DOWNLOAD_TIMEOUT_MS);
|
|
4232
4232
|
if (res.status === 404) {
|
|
@@ -4245,11 +4245,25 @@ async function downloadReportObject(auth, bucket, objectName) {
|
|
|
4245
4245
|
"Retry in a moment. If the error persists, list the reports to confirm the object still exists."
|
|
4246
4246
|
);
|
|
4247
4247
|
}
|
|
4248
|
+
const declared = Number(res.headers.get("content-length"));
|
|
4249
|
+
if (Number.isFinite(declared) && declared > maxBytes) {
|
|
4250
|
+
throw tooLargeError(objectName, maxBytes);
|
|
4251
|
+
}
|
|
4252
|
+
const body = res.body;
|
|
4253
|
+
if (!body) return Buffer.alloc(0);
|
|
4254
|
+
const chunks = [];
|
|
4255
|
+
let read = 0;
|
|
4248
4256
|
try {
|
|
4249
|
-
|
|
4257
|
+
for await (const chunk of body) {
|
|
4258
|
+
read += chunk.length;
|
|
4259
|
+
if (read > maxBytes) throw tooLargeError(objectName, maxBytes);
|
|
4260
|
+
chunks.push(Buffer.from(chunk));
|
|
4261
|
+
}
|
|
4250
4262
|
} catch (err) {
|
|
4263
|
+
if (err instanceof GpcError) throw err;
|
|
4251
4264
|
throw networkError(bucket, err);
|
|
4252
4265
|
}
|
|
4266
|
+
return Buffer.concat(chunks);
|
|
4253
4267
|
}
|
|
4254
4268
|
|
|
4255
4269
|
// src/reports/decode.ts
|
|
@@ -4285,13 +4299,15 @@ function decodeStatsCsv(buf) {
|
|
|
4285
4299
|
return decodeCsvText(gunzipIfNeeded(buf));
|
|
4286
4300
|
}
|
|
4287
4301
|
var ZIP_READ_TIMEOUT_MS = 3e4;
|
|
4288
|
-
|
|
4302
|
+
var MAX_CSV_ENTRIES = 64;
|
|
4303
|
+
function extractCsvEntriesFromZip(buffer, options = {}) {
|
|
4304
|
+
const maxEntryBytes = options.maxEntryBytes ?? MAX_INFLATED_BYTES;
|
|
4305
|
+
const maxTotalBytes = options.maxTotalBytes ?? MAX_INFLATED_BYTES;
|
|
4289
4306
|
return new Promise((resolve2, reject) => {
|
|
4290
4307
|
let settled = false;
|
|
4291
4308
|
const timer = setTimeout(() => {
|
|
4292
4309
|
settleReject(new Error("Timed out reading report archive"));
|
|
4293
4310
|
}, ZIP_READ_TIMEOUT_MS);
|
|
4294
|
-
timer.unref?.();
|
|
4295
4311
|
function settleResolve(v) {
|
|
4296
4312
|
if (settled) return;
|
|
4297
4313
|
settled = true;
|
|
@@ -4314,6 +4330,8 @@ function extractCsvEntriesFromZip(buffer) {
|
|
|
4314
4330
|
const entries = [];
|
|
4315
4331
|
let pending = 0;
|
|
4316
4332
|
let entriesDone = false;
|
|
4333
|
+
let csvEntries = 0;
|
|
4334
|
+
let totalBytes = 0;
|
|
4317
4335
|
function fail(e) {
|
|
4318
4336
|
try {
|
|
4319
4337
|
zipfile.close();
|
|
@@ -4329,6 +4347,10 @@ function extractCsvEntriesFromZip(buffer) {
|
|
|
4329
4347
|
zipfile.readEntry();
|
|
4330
4348
|
return;
|
|
4331
4349
|
}
|
|
4350
|
+
if (++csvEntries > MAX_CSV_ENTRIES) {
|
|
4351
|
+
fail(new Error(`Report archive holds more than ${MAX_CSV_ENTRIES} CSV entries`));
|
|
4352
|
+
return;
|
|
4353
|
+
}
|
|
4332
4354
|
pending++;
|
|
4333
4355
|
zipfile.openReadStream(entry, (streamErr, stream) => {
|
|
4334
4356
|
if (streamErr || !stream) {
|
|
@@ -4336,10 +4358,35 @@ function extractCsvEntriesFromZip(buffer) {
|
|
|
4336
4358
|
return;
|
|
4337
4359
|
}
|
|
4338
4360
|
const chunks = [];
|
|
4339
|
-
|
|
4361
|
+
let read = 0;
|
|
4362
|
+
stream.on("data", (c) => {
|
|
4363
|
+
read += c.length;
|
|
4364
|
+
if (read > maxEntryBytes) {
|
|
4365
|
+
stream.destroy();
|
|
4366
|
+
fail(
|
|
4367
|
+
new Error(
|
|
4368
|
+
`Entry "${entry.fileName}" exceeds the ${maxEntryBytes}-byte limit for a report CSV`
|
|
4369
|
+
)
|
|
4370
|
+
);
|
|
4371
|
+
return;
|
|
4372
|
+
}
|
|
4373
|
+
chunks.push(c);
|
|
4374
|
+
});
|
|
4340
4375
|
stream.on("error", fail);
|
|
4341
4376
|
stream.on("end", () => {
|
|
4342
|
-
|
|
4377
|
+
let text;
|
|
4378
|
+
try {
|
|
4379
|
+
text = decodeStatsCsv(Buffer.concat(chunks));
|
|
4380
|
+
} catch (e) {
|
|
4381
|
+
fail(e instanceof Error ? e : new Error(String(e)));
|
|
4382
|
+
return;
|
|
4383
|
+
}
|
|
4384
|
+
totalBytes += text.length;
|
|
4385
|
+
if (totalBytes > maxTotalBytes) {
|
|
4386
|
+
fail(new Error(`Report archive exceeds the ${maxTotalBytes}-byte total limit`));
|
|
4387
|
+
return;
|
|
4388
|
+
}
|
|
4389
|
+
entries.push({ name: entry.fileName, text });
|
|
4343
4390
|
pending--;
|
|
4344
4391
|
zipfile.readEntry();
|
|
4345
4392
|
maybeResolve();
|
|
@@ -4880,6 +4927,88 @@ async function getExternalTransaction(client, packageName, transactionId) {
|
|
|
4880
4927
|
async function refundExternalTransaction(client, packageName, transactionId, refundData) {
|
|
4881
4928
|
return client.externalTransactions.refund(packageName, transactionId, refundData);
|
|
4882
4929
|
}
|
|
4930
|
+
var RFC3339_RE = /^(\d{4}-\d{2}-\d{2})T\d{2}:\d{2}:\d{2}(?:\.\d{1,9})?(?:Z|[+-]\d{2}:\d{2})$/i;
|
|
4931
|
+
var MAX_INT64 = 2n ** 63n - 1n;
|
|
4932
|
+
var FUTURE_SKEW_MS = 5 * 60 * 1e3;
|
|
4933
|
+
function refundUsageError(message, suggestion) {
|
|
4934
|
+
return new GpcError(message, "EXT_TXN_REFUND_INVALID", 2, suggestion);
|
|
4935
|
+
}
|
|
4936
|
+
function buildExternalTransactionRefund(options, now = /* @__PURE__ */ new Date()) {
|
|
4937
|
+
const partialAmount = options.partialAmount?.trim();
|
|
4938
|
+
const currency = options.currency?.trim();
|
|
4939
|
+
const refundId = options.refundId?.trim();
|
|
4940
|
+
if (options.full && partialAmount) {
|
|
4941
|
+
throw refundUsageError(
|
|
4942
|
+
"Use --full or --partial-amount, not both",
|
|
4943
|
+
"Pass --full to refund the whole transaction, or --partial-amount for part of it."
|
|
4944
|
+
);
|
|
4945
|
+
}
|
|
4946
|
+
if (!options.full && !partialAmount) {
|
|
4947
|
+
throw refundUsageError(
|
|
4948
|
+
"Choose the refund type with --full or --partial-amount",
|
|
4949
|
+
"Pass --full, or --partial-amount <micros> --currency <code> --refund-id <id>."
|
|
4950
|
+
);
|
|
4951
|
+
}
|
|
4952
|
+
let refundTime = now.toISOString();
|
|
4953
|
+
if (options.refundTime !== void 0) {
|
|
4954
|
+
const raw = options.refundTime.trim();
|
|
4955
|
+
const match = RFC3339_RE.exec(raw);
|
|
4956
|
+
const parsed = new Date(raw);
|
|
4957
|
+
const offsetMs = parsed.getTime() - Date.parse(raw.replace(/(?:Z|[+-]\d{2}:\d{2})$/i, "Z"));
|
|
4958
|
+
const calendarDate = match ? new Date(parsed.getTime() - offsetMs).toISOString().slice(0, 10) : void 0;
|
|
4959
|
+
if (!match || Number.isNaN(parsed.getTime()) || calendarDate !== match[1]) {
|
|
4960
|
+
throw refundUsageError(
|
|
4961
|
+
`Invalid --refund-time "${options.refundTime}"`,
|
|
4962
|
+
"Use an RFC 3339 timestamp with a timezone, for example 2026-09-25T12:00:00Z or 2026-09-25T14:00:00+02:00."
|
|
4963
|
+
);
|
|
4964
|
+
}
|
|
4965
|
+
if (parsed.getTime() > now.getTime() + FUTURE_SKEW_MS) {
|
|
4966
|
+
throw refundUsageError(
|
|
4967
|
+
`--refund-time "${options.refundTime}" is in the future`,
|
|
4968
|
+
"Give the time the refund actually happened, or omit --refund-time to use now."
|
|
4969
|
+
);
|
|
4970
|
+
}
|
|
4971
|
+
refundTime = parsed.toISOString();
|
|
4972
|
+
}
|
|
4973
|
+
if (options.full) {
|
|
4974
|
+
if (currency || refundId) {
|
|
4975
|
+
throw refundUsageError(
|
|
4976
|
+
"--currency and --refund-id only apply to --partial-amount",
|
|
4977
|
+
"Remove them for a full refund, or use --partial-amount instead of --full."
|
|
4978
|
+
);
|
|
4979
|
+
}
|
|
4980
|
+
return { refundTime, fullRefund: {} };
|
|
4981
|
+
}
|
|
4982
|
+
if (!partialAmount || !/^\d{1,19}$/.test(partialAmount) || BigInt(partialAmount) <= 0n || BigInt(partialAmount) > MAX_INT64) {
|
|
4983
|
+
throw refundUsageError(
|
|
4984
|
+
`Invalid --partial-amount "${options.partialAmount}"`,
|
|
4985
|
+
"Give the pre-tax amount in micros as a positive whole number, for example 1990000 for 1.99."
|
|
4986
|
+
);
|
|
4987
|
+
}
|
|
4988
|
+
if (!currency || !/^[A-Za-z]{3}$/.test(currency)) {
|
|
4989
|
+
throw refundUsageError(
|
|
4990
|
+
currency ? `Invalid --currency "${currency}"` : "--currency is required with --partial-amount",
|
|
4991
|
+
"Use the transaction's ISO 4217 currency code, for example USD."
|
|
4992
|
+
);
|
|
4993
|
+
}
|
|
4994
|
+
if (!refundId) {
|
|
4995
|
+
throw refundUsageError(
|
|
4996
|
+
"--refund-id is required with --partial-amount",
|
|
4997
|
+
"Give each partial refund a unique ID; Google rejects a second refund with the same ID."
|
|
4998
|
+
);
|
|
4999
|
+
}
|
|
5000
|
+
return {
|
|
5001
|
+
refundTime,
|
|
5002
|
+
partialRefund: {
|
|
5003
|
+
refundId,
|
|
5004
|
+
// Normalized, so "007" is sent as "7".
|
|
5005
|
+
refundPreTaxAmount: {
|
|
5006
|
+
priceMicros: BigInt(partialAmount).toString(),
|
|
5007
|
+
currency: currency.toUpperCase()
|
|
5008
|
+
}
|
|
5009
|
+
}
|
|
5010
|
+
};
|
|
5011
|
+
}
|
|
4883
5012
|
|
|
4884
5013
|
// src/commands/device-tiers.ts
|
|
4885
5014
|
async function listDeviceTiers(client, packageName) {
|
|
@@ -5376,11 +5505,11 @@ async function getAchievementConfig(client, achievementId) {
|
|
|
5376
5505
|
}
|
|
5377
5506
|
async function createAchievementConfig(client, applicationId, data) {
|
|
5378
5507
|
validateApplicationId(applicationId);
|
|
5379
|
-
return client.achievements.insert(applicationId, data);
|
|
5508
|
+
return client.achievements.insert(applicationId, withoutRedactedToken(data));
|
|
5380
5509
|
}
|
|
5381
5510
|
async function updateAchievementConfig(client, achievementId, data) {
|
|
5382
5511
|
validateResourceId(achievementId, "achievement");
|
|
5383
|
-
return client.achievements.update(achievementId, data);
|
|
5512
|
+
return client.achievements.update(achievementId, withoutRedactedToken(data));
|
|
5384
5513
|
}
|
|
5385
5514
|
async function deleteAchievementConfig(client, achievementId) {
|
|
5386
5515
|
validateResourceId(achievementId, "achievement");
|
|
@@ -5412,11 +5541,11 @@ async function getLeaderboardConfig(client, leaderboardId) {
|
|
|
5412
5541
|
}
|
|
5413
5542
|
async function createLeaderboardConfig(client, applicationId, data) {
|
|
5414
5543
|
validateApplicationId(applicationId);
|
|
5415
|
-
return client.leaderboards.insert(applicationId, data);
|
|
5544
|
+
return client.leaderboards.insert(applicationId, withoutRedactedToken(data));
|
|
5416
5545
|
}
|
|
5417
5546
|
async function updateLeaderboardConfig(client, leaderboardId, data) {
|
|
5418
5547
|
validateResourceId(leaderboardId, "leaderboard");
|
|
5419
|
-
return client.leaderboards.update(leaderboardId, data);
|
|
5548
|
+
return client.leaderboards.update(leaderboardId, withoutRedactedToken(data));
|
|
5420
5549
|
}
|
|
5421
5550
|
async function deleteLeaderboardConfig(client, leaderboardId) {
|
|
5422
5551
|
validateResourceId(leaderboardId, "leaderboard");
|
|
@@ -5474,6 +5603,11 @@ async function setLeaderboardIcon(client, leaderboardId, filePath) {
|
|
|
5474
5603
|
iconContentType(filePath)
|
|
5475
5604
|
);
|
|
5476
5605
|
}
|
|
5606
|
+
function withoutRedactedToken(config) {
|
|
5607
|
+
if (config.token !== REDACTED) return config;
|
|
5608
|
+
const { token: _token, ...rest } = config;
|
|
5609
|
+
return rest;
|
|
5610
|
+
}
|
|
5477
5611
|
function safeConfigFilename(id) {
|
|
5478
5612
|
return `${id.replace(/[^a-zA-Z0-9_-]/g, "_")}.json`;
|
|
5479
5613
|
}
|
|
@@ -5501,7 +5635,7 @@ async function readConfigDir(dir) {
|
|
|
5501
5635
|
"Each file must contain a single achievement or leaderboard config object."
|
|
5502
5636
|
);
|
|
5503
5637
|
}
|
|
5504
|
-
out.push({ file, config: parsed });
|
|
5638
|
+
out.push({ file, config: withoutRedactedToken(parsed) });
|
|
5505
5639
|
}
|
|
5506
5640
|
return out;
|
|
5507
5641
|
}
|
|
@@ -5832,7 +5966,7 @@ async function initProject(options) {
|
|
|
5832
5966
|
{
|
|
5833
5967
|
failOn: "error",
|
|
5834
5968
|
targetSdkMinimum: 36,
|
|
5835
|
-
maxDownloadSizeMb:
|
|
5969
|
+
maxDownloadSizeMb: 200,
|
|
5836
5970
|
allowedPermissions: [],
|
|
5837
5971
|
disabledRules: [],
|
|
5838
5972
|
severityOverrides: {}
|
|
@@ -5950,7 +6084,7 @@ var SEVERITY_ORDER = {
|
|
|
5950
6084
|
var DEFAULT_PREFLIGHT_CONFIG = {
|
|
5951
6085
|
failOn: "error",
|
|
5952
6086
|
targetSdkMinimum: 36,
|
|
5953
|
-
maxDownloadSizeMb:
|
|
6087
|
+
maxDownloadSizeMb: 200,
|
|
5954
6088
|
allowedPermissions: [],
|
|
5955
6089
|
disabledRules: [],
|
|
5956
6090
|
severityOverrides: {}
|
|
@@ -6083,13 +6217,23 @@ function getSchema() {
|
|
|
6083
6217
|
return cachedSchema;
|
|
6084
6218
|
}
|
|
6085
6219
|
function decodeManifest(buf) {
|
|
6220
|
+
return extractManifestData(decodeManifestElement(buf));
|
|
6221
|
+
}
|
|
6222
|
+
function isInstallTimeModule(buf) {
|
|
6223
|
+
const module = getChildren(decodeManifestElement(buf), "module")[0];
|
|
6224
|
+
if (!module) return true;
|
|
6225
|
+
const delivery = getChildren(module, "delivery")[0];
|
|
6226
|
+
if (delivery) return getChildren(delivery, "install-time").length > 0;
|
|
6227
|
+
return !getBoolByName(module.attribute || [], "onDemand", false);
|
|
6228
|
+
}
|
|
6229
|
+
function decodeManifestElement(buf) {
|
|
6086
6230
|
const root = getSchema();
|
|
6087
6231
|
const XmlNode = root.lookupType("aapt.pb.XmlNode");
|
|
6088
6232
|
const decoded = XmlNode.decode(buf);
|
|
6089
6233
|
if (!decoded.element || decoded.element.name !== "manifest") {
|
|
6090
6234
|
throw new Error("Invalid AAB manifest: root element is not <manifest>");
|
|
6091
6235
|
}
|
|
6092
|
-
return
|
|
6236
|
+
return decoded.element;
|
|
6093
6237
|
}
|
|
6094
6238
|
function getAttrByName(attrs, name) {
|
|
6095
6239
|
const attr = attrs.find((a) => a.name === name || RESOURCE_IDS[a.resourceId] === name);
|
|
@@ -6198,6 +6342,10 @@ var AAB_MANIFEST_PATH = "base/manifest/AndroidManifest.xml";
|
|
|
6198
6342
|
var APK_MANIFEST_PATH = "AndroidManifest.xml";
|
|
6199
6343
|
var SO_HEADER_BYTES = 4096;
|
|
6200
6344
|
var SO_PATH_RE = /(?:^|\/)lib\/[^/]+\/[^/]+\.so$/;
|
|
6345
|
+
var MODULE_MANIFEST_RE = /^([^/]+)\/manifest\/AndroidManifest\.xml$/;
|
|
6346
|
+
var MAX_MODULE_MANIFEST_BYTES = 1024 * 1024;
|
|
6347
|
+
var MAX_MODULE_MANIFESTS = 256;
|
|
6348
|
+
var MAX_SO_HEADER_READS = 512;
|
|
6201
6349
|
var SCAN_TIMEOUT_MS = 6e4;
|
|
6202
6350
|
function detectManifestPath(filePath) {
|
|
6203
6351
|
return filePath.toLowerCase().endsWith(".apk") ? APK_MANIFEST_PATH : AAB_MANIFEST_PATH;
|
|
@@ -6211,7 +6359,10 @@ async function readAab(aabPath) {
|
|
|
6211
6359
|
const msg = err instanceof Error ? err.message : String(err);
|
|
6212
6360
|
throw new Error(`Could not read ${aabPath}: ${msg}`, { cause: err });
|
|
6213
6361
|
}
|
|
6214
|
-
const { zipfile, entries, manifestBuf, soHeaders } = await openAndScan(
|
|
6362
|
+
const { zipfile, entries, manifestBuf, soHeaders, moduleManifests } = await openAndScan(
|
|
6363
|
+
buffer,
|
|
6364
|
+
manifestPath
|
|
6365
|
+
);
|
|
6215
6366
|
zipfile.close();
|
|
6216
6367
|
if (!manifestBuf) {
|
|
6217
6368
|
const fileType = aabPath.toLowerCase().endsWith(".apk") ? "APK" : "AAB";
|
|
@@ -6227,7 +6378,20 @@ async function readAab(aabPath) {
|
|
|
6227
6378
|
manifest = createFallbackManifest();
|
|
6228
6379
|
manifest._parseError = `Manifest could not be fully parsed: ${errMsg}. Manifest-dependent checks will be skipped.`;
|
|
6229
6380
|
}
|
|
6230
|
-
|
|
6381
|
+
const deferredModules = [...moduleManifests].filter(([, buf]) => {
|
|
6382
|
+
try {
|
|
6383
|
+
return !isInstallTimeModule(buf);
|
|
6384
|
+
} catch {
|
|
6385
|
+
return false;
|
|
6386
|
+
}
|
|
6387
|
+
}).map(([name]) => name);
|
|
6388
|
+
return {
|
|
6389
|
+
manifest,
|
|
6390
|
+
entries,
|
|
6391
|
+
nativeLibHeaders: soHeaders,
|
|
6392
|
+
isAppBundle: manifestPath === AAB_MANIFEST_PATH,
|
|
6393
|
+
deferredModules
|
|
6394
|
+
};
|
|
6231
6395
|
}
|
|
6232
6396
|
function createFallbackManifest() {
|
|
6233
6397
|
return {
|
|
@@ -6277,6 +6441,9 @@ function openAndScan(buffer, manifestPath = AAB_MANIFEST_PATH) {
|
|
|
6277
6441
|
}
|
|
6278
6442
|
const entries = [];
|
|
6279
6443
|
const soHeaders = /* @__PURE__ */ new Map();
|
|
6444
|
+
const moduleManifests = /* @__PURE__ */ new Map();
|
|
6445
|
+
let moduleManifestReads = 0;
|
|
6446
|
+
let soHeaderReads = 0;
|
|
6280
6447
|
let manifestBuf = null;
|
|
6281
6448
|
let pendingStreams = 0;
|
|
6282
6449
|
let entrysDone = false;
|
|
@@ -6290,19 +6457,34 @@ function openAndScan(buffer, manifestPath = AAB_MANIFEST_PATH) {
|
|
|
6290
6457
|
}
|
|
6291
6458
|
function maybeResolve() {
|
|
6292
6459
|
if (!settled && entrysDone && pendingStreams === 0) {
|
|
6293
|
-
settleResolve({ zipfile, entries, manifestBuf, soHeaders });
|
|
6460
|
+
settleResolve({ zipfile, entries, manifestBuf, soHeaders, moduleManifests });
|
|
6294
6461
|
}
|
|
6295
6462
|
}
|
|
6296
|
-
function
|
|
6463
|
+
function advance() {
|
|
6464
|
+
if (settled) return;
|
|
6465
|
+
zipfile.readEntry();
|
|
6466
|
+
}
|
|
6467
|
+
function readEntryStream(entry, onData, maxBytes, optional = false) {
|
|
6297
6468
|
pendingStreams++;
|
|
6469
|
+
let done = false;
|
|
6470
|
+
const onError = (e) => {
|
|
6471
|
+
if (!optional) {
|
|
6472
|
+
fail(e);
|
|
6473
|
+
return;
|
|
6474
|
+
}
|
|
6475
|
+
if (done) return;
|
|
6476
|
+
done = true;
|
|
6477
|
+
pendingStreams--;
|
|
6478
|
+
advance();
|
|
6479
|
+
maybeResolve();
|
|
6480
|
+
};
|
|
6298
6481
|
zipfile.openReadStream(entry, (streamErr, stream) => {
|
|
6299
6482
|
if (streamErr || !stream) {
|
|
6300
|
-
|
|
6483
|
+
onError(streamErr ?? new Error(`Failed to read ${entry.fileName}`));
|
|
6301
6484
|
return;
|
|
6302
6485
|
}
|
|
6303
6486
|
const chunks = [];
|
|
6304
6487
|
let totalBytes = 0;
|
|
6305
|
-
let done = false;
|
|
6306
6488
|
stream.on("data", (chunk) => {
|
|
6307
6489
|
chunks.push(chunk);
|
|
6308
6490
|
totalBytes += chunk.length;
|
|
@@ -6312,7 +6494,7 @@ function openAndScan(buffer, manifestPath = AAB_MANIFEST_PATH) {
|
|
|
6312
6494
|
});
|
|
6313
6495
|
stream.on("error", (e) => {
|
|
6314
6496
|
if (maxBytes && totalBytes >= maxBytes) return;
|
|
6315
|
-
|
|
6497
|
+
onError(e);
|
|
6316
6498
|
});
|
|
6317
6499
|
const finish = () => {
|
|
6318
6500
|
if (done) return;
|
|
@@ -6320,6 +6502,7 @@ function openAndScan(buffer, manifestPath = AAB_MANIFEST_PATH) {
|
|
|
6320
6502
|
const buf = Buffer.concat(chunks);
|
|
6321
6503
|
onData(maxBytes ? buf.subarray(0, maxBytes) : buf);
|
|
6322
6504
|
pendingStreams--;
|
|
6505
|
+
advance();
|
|
6323
6506
|
maybeResolve();
|
|
6324
6507
|
};
|
|
6325
6508
|
stream.on("end", finish);
|
|
@@ -6340,11 +6523,24 @@ function openAndScan(buffer, manifestPath = AAB_MANIFEST_PATH) {
|
|
|
6340
6523
|
uncompressedSize: entry.uncompressedSize
|
|
6341
6524
|
});
|
|
6342
6525
|
}
|
|
6526
|
+
const moduleName = manifestPath === AAB_MANIFEST_PATH ? MODULE_MANIFEST_RE.exec(path)?.[1] : void 0;
|
|
6527
|
+
const readModuleManifest = moduleName !== void 0 && entry.uncompressedSize <= MAX_MODULE_MANIFEST_BYTES && moduleManifestReads < MAX_MODULE_MANIFESTS;
|
|
6343
6528
|
if (path === manifestPath) {
|
|
6344
6529
|
readEntryStream(entry, (buf) => {
|
|
6345
6530
|
manifestBuf = buf;
|
|
6346
6531
|
});
|
|
6347
|
-
} else if (
|
|
6532
|
+
} else if (moduleName !== void 0 && readModuleManifest) {
|
|
6533
|
+
moduleManifestReads++;
|
|
6534
|
+
readEntryStream(
|
|
6535
|
+
entry,
|
|
6536
|
+
(buf) => {
|
|
6537
|
+
moduleManifests.set(moduleName, buf);
|
|
6538
|
+
},
|
|
6539
|
+
MAX_MODULE_MANIFEST_BYTES,
|
|
6540
|
+
true
|
|
6541
|
+
);
|
|
6542
|
+
} else if (SO_PATH_RE.test(path) && soHeaderReads < MAX_SO_HEADER_READS) {
|
|
6543
|
+
soHeaderReads++;
|
|
6348
6544
|
readEntryStream(
|
|
6349
6545
|
entry,
|
|
6350
6546
|
(buf) => {
|
|
@@ -6352,8 +6548,9 @@ function openAndScan(buffer, manifestPath = AAB_MANIFEST_PATH) {
|
|
|
6352
6548
|
},
|
|
6353
6549
|
SO_HEADER_BYTES
|
|
6354
6550
|
);
|
|
6551
|
+
} else {
|
|
6552
|
+
advance();
|
|
6355
6553
|
}
|
|
6356
|
-
zipfile.readEntry();
|
|
6357
6554
|
} catch (e) {
|
|
6358
6555
|
fail(e instanceof Error ? e : new Error(String(e)));
|
|
6359
6556
|
}
|
|
@@ -6457,20 +6654,18 @@ var manifestScanner = {
|
|
|
6457
6654
|
}
|
|
6458
6655
|
if (manifest.targetSdk >= 34) {
|
|
6459
6656
|
const hasFgsPerm = manifest.permissions.includes("android.permission.FOREGROUND_SERVICE");
|
|
6460
|
-
|
|
6461
|
-
|
|
6462
|
-
|
|
6463
|
-
|
|
6464
|
-
|
|
6465
|
-
|
|
6466
|
-
|
|
6467
|
-
|
|
6468
|
-
|
|
6469
|
-
|
|
6470
|
-
|
|
6471
|
-
|
|
6472
|
-
}
|
|
6473
|
-
}
|
|
6657
|
+
const anyTyped = manifest.services.some((s) => s.foregroundServiceType);
|
|
6658
|
+
if (hasFgsPerm && !anyTyped && manifest.services.length > 0) {
|
|
6659
|
+
const typedPerms = manifest.permissions.filter((p) => p.startsWith("android.permission.FOREGROUND_SERVICE_")).map((p) => p.replace("android.permission.", ""));
|
|
6660
|
+
findings.push({
|
|
6661
|
+
scanner: "manifest",
|
|
6662
|
+
ruleId: "foreground-service-type-missing",
|
|
6663
|
+
severity: "warning",
|
|
6664
|
+
title: "No service declares a foregroundServiceType",
|
|
6665
|
+
message: `The app requests FOREGROUND_SERVICE${typedPerms.length > 0 ? ` (and ${typedPerms.join(", ")})` : ""} but none of its ${manifest.services.length} services declares android:foregroundServiceType. On API 34+, any service that calls startForeground() must declare a type or it crashes at runtime. Services that never run in the foreground do not need one.`,
|
|
6666
|
+
suggestion: 'Add android:foregroundServiceType to each service you start in the foreground (for example dataSync, mediaPlayback, location). If none do, remove the FOREGROUND_SERVICE permission or add this rule to "disabledRules".',
|
|
6667
|
+
policyUrl: "https://developer.android.com/about/versions/14/changes/fgs-types-required"
|
|
6668
|
+
});
|
|
6474
6669
|
}
|
|
6475
6670
|
}
|
|
6476
6671
|
const hasBackgroundLocation = manifest.permissions.includes(
|
|
@@ -6788,9 +6983,36 @@ var permissionsScanner = {
|
|
|
6788
6983
|
}
|
|
6789
6984
|
};
|
|
6790
6985
|
|
|
6986
|
+
// src/preflight/bundle-layout.ts
|
|
6987
|
+
var LIB_PATH_RE = /^(?:[^/]+\/)?lib\/([^/]+)\/[^/]+\.so$/;
|
|
6988
|
+
function isBundleMetadata(path) {
|
|
6989
|
+
return path.startsWith("BUNDLE-METADATA/") || path.startsWith("META-INF/") || path === "BundleConfig.pb";
|
|
6990
|
+
}
|
|
6991
|
+
function nativeLibAbi(path) {
|
|
6992
|
+
return LIB_PATH_RE.exec(path)?.[1];
|
|
6993
|
+
}
|
|
6994
|
+
function firstInstallEntries(entries, isAppBundle, deferredModules = []) {
|
|
6995
|
+
if (!isAppBundle) return [...entries];
|
|
6996
|
+
const deferred = new Set(deferredModules);
|
|
6997
|
+
return entries.filter(
|
|
6998
|
+
(e) => !isBundleMetadata(e.path) && !deferred.has(e.path.split("/", 1)[0] ?? "")
|
|
6999
|
+
);
|
|
7000
|
+
}
|
|
7001
|
+
function largestAbi(entries, size) {
|
|
7002
|
+
const perAbi = /* @__PURE__ */ new Map();
|
|
7003
|
+
for (const entry of entries) {
|
|
7004
|
+
const abi = nativeLibAbi(entry.path);
|
|
7005
|
+
if (abi) perAbi.set(abi, (perAbi.get(abi) ?? 0) + size(entry));
|
|
7006
|
+
}
|
|
7007
|
+
let largest;
|
|
7008
|
+
for (const [abi, bytes] of perAbi) {
|
|
7009
|
+
if (!largest || bytes > largest.bytes) largest = { abi, bytes };
|
|
7010
|
+
}
|
|
7011
|
+
return largest;
|
|
7012
|
+
}
|
|
7013
|
+
|
|
6791
7014
|
// src/preflight/scanners/native-libs-scanner.ts
|
|
6792
7015
|
var KNOWN_ABIS = ["arm64-v8a", "armeabi-v7a", "x86", "x86_64"];
|
|
6793
|
-
var LIB_PATH_RE = /^(?:[^/]+\/)?lib\/([^/]+)\/[^/]+\.so$/;
|
|
6794
7016
|
var ELF_MAGIC = 2135247942;
|
|
6795
7017
|
var PT_LOAD = 1;
|
|
6796
7018
|
var PAGE_16KB = 16384;
|
|
@@ -6848,7 +7070,7 @@ var nativeLibsScanner = {
|
|
|
6848
7070
|
const abisFound = /* @__PURE__ */ new Set();
|
|
6849
7071
|
let totalNativeSize = 0;
|
|
6850
7072
|
for (const entry of entries) {
|
|
6851
|
-
const abi =
|
|
7073
|
+
const abi = nativeLibAbi(entry.path);
|
|
6852
7074
|
if (abi) {
|
|
6853
7075
|
abisFound.add(abi);
|
|
6854
7076
|
totalNativeSize += entry.uncompressedSize;
|
|
@@ -6884,6 +7106,20 @@ var nativeLibsScanner = {
|
|
|
6884
7106
|
});
|
|
6885
7107
|
}
|
|
6886
7108
|
check16KBAlignment(ctx.nativeLibHeaders, ctx.manifest, findings);
|
|
7109
|
+
if (ctx.nativeLibHeaders) {
|
|
7110
|
+
const soCount = entries.filter((e) => nativeLibAbi(e.path) && e.path.endsWith(".so")).length;
|
|
7111
|
+
const unchecked = soCount - ctx.nativeLibHeaders.size;
|
|
7112
|
+
if (unchecked > 0) {
|
|
7113
|
+
findings.push({
|
|
7114
|
+
scanner: "native-libs",
|
|
7115
|
+
ruleId: "native-libs-16kb-unchecked",
|
|
7116
|
+
severity: "warning",
|
|
7117
|
+
title: `${unchecked} of ${soCount} native libraries not checked for 16KB alignment`,
|
|
7118
|
+
message: `Preflight checked the first ${ctx.nativeLibHeaders.size} native libraries in this bundle; the remaining ${unchecked} were not inspected, so a misaligned library among them would not be reported.`,
|
|
7119
|
+
suggestion: "Verify alignment of the full bundle with Android Studio's APK Analyzer or `zipalign -c -P 16 -v 4 app.apk` on a universal APK."
|
|
7120
|
+
});
|
|
7121
|
+
}
|
|
7122
|
+
}
|
|
6887
7123
|
const detectedAbis = KNOWN_ABIS.filter((abi) => abisFound.has(abi));
|
|
6888
7124
|
const unknownAbis = [...abisFound].filter(
|
|
6889
7125
|
(abi) => !KNOWN_ABIS.includes(abi)
|
|
@@ -6897,14 +7133,21 @@ var nativeLibsScanner = {
|
|
|
6897
7133
|
title: `Native libraries: ${abiList}`,
|
|
6898
7134
|
message: `Found native libraries for ${abisFound.size} architecture(s): ${abiList}. Total uncompressed size: ${sizeMb} MB.`
|
|
6899
7135
|
});
|
|
6900
|
-
|
|
7136
|
+
const bundle = ctx.isAppBundle ?? false;
|
|
7137
|
+
const perDevice = bundle ? largestAbi(
|
|
7138
|
+
firstInstallEntries(entries, true, ctx.deferredModules),
|
|
7139
|
+
(e) => e.uncompressedSize
|
|
7140
|
+
) : void 0;
|
|
7141
|
+
const perDeviceSize = bundle ? perDevice?.bytes ?? 0 : totalNativeSize;
|
|
7142
|
+
if (perDeviceSize > 150 * 1024 * 1024) {
|
|
7143
|
+
const perDeviceMb = (perDeviceSize / (1024 * 1024)).toFixed(1);
|
|
6901
7144
|
findings.push({
|
|
6902
7145
|
scanner: "native-libs",
|
|
6903
7146
|
ruleId: "native-libs-large",
|
|
6904
7147
|
severity: "warning",
|
|
6905
7148
|
title: "Large native libraries",
|
|
6906
|
-
message: `Native libraries total ${sizeMb} MB (uncompressed). This significantly increases download size.`,
|
|
6907
|
-
suggestion: "Consider using Android App Bundles to deliver only the required ABI per device. Review if all native libraries are necessary."
|
|
7149
|
+
message: perDevice ? `Native libraries for ${perDevice.abi} total ${perDeviceMb} MB (uncompressed). Each device on that ABI downloads all of them.` : `Native libraries total ${sizeMb} MB (uncompressed). This significantly increases download size.`,
|
|
7150
|
+
suggestion: perDevice ? "Review whether all native libraries are necessary, and move optional native code into on-demand feature modules." : "Consider using Android App Bundles to deliver only the required ABI per device. Review if all native libraries are necessary."
|
|
6908
7151
|
});
|
|
6909
7152
|
}
|
|
6910
7153
|
return findings;
|
|
@@ -7559,6 +7802,9 @@ var policyScanner = {
|
|
|
7559
7802
|
};
|
|
7560
7803
|
|
|
7561
7804
|
// src/preflight/scanners/size-scanner.ts
|
|
7805
|
+
var MB = 1024 * 1024;
|
|
7806
|
+
var PLAY_MOBILE_DATA_DIALOG_MB = 200;
|
|
7807
|
+
var mb = (bytes) => (bytes / MB).toFixed(1);
|
|
7562
7808
|
var sizeScanner = {
|
|
7563
7809
|
name: "size",
|
|
7564
7810
|
description: "Analyzes app bundle size and warns on large downloads",
|
|
@@ -7572,21 +7818,28 @@ var sizeScanner = {
|
|
|
7572
7818
|
const maxMb = ctx.config.maxDownloadSizeMb;
|
|
7573
7819
|
const totalCompressed = entries.reduce((sum, e) => sum + e.compressedSize, 0);
|
|
7574
7820
|
const totalUncompressed = entries.reduce((sum, e) => sum + e.uncompressedSize, 0);
|
|
7575
|
-
const
|
|
7576
|
-
const
|
|
7577
|
-
|
|
7821
|
+
const bundle = ctx.isAppBundle ?? false;
|
|
7822
|
+
const installed = firstInstallEntries(entries, bundle, ctx.deferredModules);
|
|
7823
|
+
const nativeAbi = bundle ? largestAbi(installed, (e) => e.compressedSize) : void 0;
|
|
7824
|
+
const delivered = bundle ? installed.filter((e) => {
|
|
7825
|
+
const abi = nativeLibAbi(e.path);
|
|
7826
|
+
return !abi || abi === nativeAbi?.abi;
|
|
7827
|
+
}) : installed;
|
|
7828
|
+
const downloadBytes = delivered.reduce((sum, e) => sum + e.compressedSize, 0);
|
|
7829
|
+
if (downloadBytes / MB > maxMb) {
|
|
7830
|
+
const size = bundle ? `Estimated download for one device is ${mb(downloadBytes)} MB${nativeAbi ? ` (with ${nativeAbi.abi} native libraries)` : ""}` : `The APK is ${mb(downloadBytes)} MB compressed`;
|
|
7578
7831
|
findings.push({
|
|
7579
7832
|
scanner: "size",
|
|
7580
7833
|
ruleId: "size-over-limit",
|
|
7581
7834
|
severity: "warning",
|
|
7582
7835
|
title: `Download size exceeds ${maxMb} MB`,
|
|
7583
|
-
message:
|
|
7584
|
-
suggestion: "
|
|
7585
|
-
policyUrl: "https://
|
|
7836
|
+
message: `${size}, above your ${maxMb} MB threshold.` + (downloadBytes > PLAY_MOBILE_DATA_DIALOG_MB * MB ? ` Google Play shows users on mobile data a large-download dialog for apps over ${PLAY_MOBILE_DATA_DIALOG_MB} MB, which can reduce install rates.` : ""),
|
|
7837
|
+
suggestion: bundle ? "Remove unused resources, enable R8, and move large or optional content into fast-follow or on-demand asset packs or feature modules." : "Publish an Android App Bundle so each device downloads only its own ABI and screen density, remove unused resources, and enable R8.",
|
|
7838
|
+
policyUrl: "https://support.google.com/googleplay/android-developer/answer/9859372"
|
|
7586
7839
|
});
|
|
7587
7840
|
}
|
|
7588
7841
|
const categories = /* @__PURE__ */ new Map();
|
|
7589
|
-
for (const entry of
|
|
7842
|
+
for (const entry of delivered) {
|
|
7590
7843
|
const cat = detectCategory(entry.path);
|
|
7591
7844
|
const existing = categories.get(cat) ?? { compressed: 0, uncompressed: 0, count: 0 };
|
|
7592
7845
|
existing.compressed += entry.compressedSize;
|
|
@@ -7595,13 +7848,13 @@ var sizeScanner = {
|
|
|
7595
7848
|
categories.set(cat, existing);
|
|
7596
7849
|
}
|
|
7597
7850
|
const nativeLibs = categories.get("native-libs");
|
|
7598
|
-
if (nativeLibs && nativeLibs.compressed > 50 *
|
|
7851
|
+
if (nativeLibs && nativeLibs.compressed > 50 * MB) {
|
|
7599
7852
|
findings.push({
|
|
7600
7853
|
scanner: "size",
|
|
7601
7854
|
ruleId: "size-large-native",
|
|
7602
7855
|
severity: "warning",
|
|
7603
7856
|
title: "Large native libraries",
|
|
7604
|
-
message: `Native libraries are ${(nativeLibs.compressed
|
|
7857
|
+
message: nativeAbi ? `Native libraries for ${nativeAbi.abi} are ${mb(nativeLibs.compressed)} MB (compressed), and each device on that ABI downloads all of them.` : `Native libraries are ${mb(nativeLibs.compressed)} MB (compressed) across every ABI in the APK.`,
|
|
7605
7858
|
suggestion: "Review which native libraries are bundled. Consider using dynamic feature modules for optional native code."
|
|
7606
7859
|
});
|
|
7607
7860
|
}
|
|
@@ -7612,18 +7865,18 @@ var sizeScanner = {
|
|
|
7612
7865
|
ruleId: "size-large-assets",
|
|
7613
7866
|
severity: "info",
|
|
7614
7867
|
title: "Large assets directory",
|
|
7615
|
-
message: `Assets are ${(assets.compressed
|
|
7868
|
+
message: `Assets are ${mb(assets.compressed)} MB (compressed). Consider using Play Asset Delivery for large assets.`,
|
|
7616
7869
|
suggestion: "Move large assets to Play Asset Delivery (install-time, fast-follow, or on-demand packs).",
|
|
7617
7870
|
policyUrl: "https://developer.android.com/guide/playcore/asset-delivery"
|
|
7618
7871
|
});
|
|
7619
7872
|
}
|
|
7620
|
-
const breakdown = [...categories.entries()].sort((a, b) => b[1].compressed - a[1].compressed).map(([cat, data]) => `${cat}: ${(data.compressed
|
|
7873
|
+
const breakdown = [...categories.entries()].sort((a, b) => b[1].compressed - a[1].compressed).map(([cat, data]) => `${cat}: ${mb(data.compressed)} MB`).join(", ");
|
|
7621
7874
|
findings.push({
|
|
7622
7875
|
scanner: "size",
|
|
7623
7876
|
ruleId: "size-summary",
|
|
7624
7877
|
severity: "info",
|
|
7625
|
-
title: `Total size: ${
|
|
7626
|
-
message: `${entries.length} files. Breakdown: ${breakdown}`
|
|
7878
|
+
title: bundle ? `Estimated download: ${mb(downloadBytes)} MB per device (bundle file ${mb(totalCompressed)} MB compressed, ${mb(totalUncompressed)} MB uncompressed)` : `Total size: ${mb(totalCompressed)} MB compressed, ${mb(totalUncompressed)} MB uncompressed`,
|
|
7879
|
+
message: bundle ? `${delivered.length} of ${entries.length} files reach one device. Breakdown: ${breakdown}` : `${entries.length} files. Breakdown: ${breakdown}`
|
|
7627
7880
|
});
|
|
7628
7881
|
return findings;
|
|
7629
7882
|
}
|
|
@@ -7631,10 +7884,10 @@ var sizeScanner = {
|
|
|
7631
7884
|
function detectCategory(path) {
|
|
7632
7885
|
const lower = path.toLowerCase();
|
|
7633
7886
|
if (lower.endsWith(".dex") || /\/dex\//.test(lower)) return "dex";
|
|
7634
|
-
if (
|
|
7635
|
-
if (
|
|
7887
|
+
if (nativeLibAbi(path)) return "native-libs";
|
|
7888
|
+
if (/(?:^|\/)res\//.test(lower) || /(?:^|\/)resources\.(?:pb|arsc)$/.test(lower))
|
|
7636
7889
|
return "resources";
|
|
7637
|
-
if (
|
|
7890
|
+
if (/(?:^|\/)assets\//.test(lower)) return "assets";
|
|
7638
7891
|
if (lower.includes("androidmanifest.xml") || /\/manifest\//.test(lower)) return "manifest";
|
|
7639
7892
|
if (lower.startsWith("meta-inf/")) return "signing";
|
|
7640
7893
|
return "other";
|
|
@@ -7670,6 +7923,8 @@ async function runPreflight(options) {
|
|
|
7670
7923
|
ctx.manifest = aab.manifest;
|
|
7671
7924
|
ctx.zipEntries = aab.entries;
|
|
7672
7925
|
ctx.nativeLibHeaders = aab.nativeLibHeaders;
|
|
7926
|
+
ctx.isAppBundle = aab.isAppBundle;
|
|
7927
|
+
ctx.deferredModules = aab.deferredModules;
|
|
7673
7928
|
if (aab.manifest._parseError) {
|
|
7674
7929
|
earlyFindings.push({
|
|
7675
7930
|
scanner: "manifest-parser",
|
|
@@ -9628,7 +9883,7 @@ async function sha256File(filePath) {
|
|
|
9628
9883
|
}
|
|
9629
9884
|
|
|
9630
9885
|
// src/commands/image-sync.ts
|
|
9631
|
-
import { readdir as readdir7
|
|
9886
|
+
import { readdir as readdir7 } from "fs/promises";
|
|
9632
9887
|
import { join as join13, extname as extname5 } from "path";
|
|
9633
9888
|
import { PlayApiError } from "@gpc-cli/api";
|
|
9634
9889
|
var IMAGE_EXTENSIONS = /* @__PURE__ */ new Set([".png", ".jpg", ".jpeg"]);
|
|
@@ -9643,12 +9898,20 @@ var ALL_IMAGE_TYPES2 = [
|
|
|
9643
9898
|
"wearScreenshots"
|
|
9644
9899
|
];
|
|
9645
9900
|
async function scanLocalImages(dir) {
|
|
9901
|
+
let entries;
|
|
9646
9902
|
try {
|
|
9647
|
-
|
|
9648
|
-
|
|
9649
|
-
|
|
9650
|
-
return
|
|
9903
|
+
entries = await readdir7(dir, { withFileTypes: true });
|
|
9904
|
+
} catch (error) {
|
|
9905
|
+
const code = error.code;
|
|
9906
|
+
if (code === "ENOENT" || code === "ENOTDIR") return null;
|
|
9907
|
+
throw new GpcError(
|
|
9908
|
+
`Cannot read image directory "${dir}": ${code ?? String(error)}`,
|
|
9909
|
+
"IMAGE_SYNC_DIR_UNREADABLE",
|
|
9910
|
+
1,
|
|
9911
|
+
"Check the directory's permissions (and container volume ownership in CI). Nothing was changed on Google Play."
|
|
9912
|
+
);
|
|
9651
9913
|
}
|
|
9914
|
+
return entries.filter((e) => e.isFile() && IMAGE_EXTENSIONS.has(extname5(e.name).toLowerCase())).map((e) => e.name).sort();
|
|
9652
9915
|
}
|
|
9653
9916
|
async function scanLanguages(dir) {
|
|
9654
9917
|
try {
|
|
@@ -9658,13 +9921,6 @@ async function scanLanguages(dir) {
|
|
|
9658
9921
|
return [];
|
|
9659
9922
|
}
|
|
9660
9923
|
}
|
|
9661
|
-
async function dirExists(path) {
|
|
9662
|
-
try {
|
|
9663
|
-
return (await stat10(path)).isDirectory();
|
|
9664
|
-
} catch {
|
|
9665
|
-
return false;
|
|
9666
|
-
}
|
|
9667
|
-
}
|
|
9668
9924
|
async function syncImages(client, packageName, dir, options) {
|
|
9669
9925
|
const details = [];
|
|
9670
9926
|
let uploaded = 0;
|
|
@@ -9685,7 +9941,8 @@ async function syncImages(client, packageName, dir, options) {
|
|
|
9685
9941
|
for (const language of languages) {
|
|
9686
9942
|
for (const imageType of imageTypes) {
|
|
9687
9943
|
const localDir = join13(dir, language, imageType);
|
|
9688
|
-
const
|
|
9944
|
+
const scanned = await scanLocalImages(localDir);
|
|
9945
|
+
const localFiles = scanned ?? [];
|
|
9689
9946
|
let remoteImages;
|
|
9690
9947
|
try {
|
|
9691
9948
|
remoteImages = await client.images.list(packageName, edit.id, language, imageType);
|
|
@@ -9727,7 +9984,7 @@ async function syncImages(client, packageName, dir, options) {
|
|
|
9727
9984
|
details.push({ language, imageType, file, action: "upload", reason });
|
|
9728
9985
|
};
|
|
9729
9986
|
if (options?.delete) {
|
|
9730
|
-
if (
|
|
9987
|
+
if (scanned === null && remoteImages.length > 0) {
|
|
9731
9988
|
for (const img of remoteImages) {
|
|
9732
9989
|
details.push({
|
|
9733
9990
|
language,
|
|
@@ -10151,6 +10408,7 @@ export {
|
|
|
10151
10408
|
batchGetOrders,
|
|
10152
10409
|
batchSyncInAppProducts,
|
|
10153
10410
|
buildChecklist,
|
|
10411
|
+
buildExternalTransactionRefund,
|
|
10154
10412
|
buildLocaleBundle,
|
|
10155
10413
|
bundleToReleaseNotes,
|
|
10156
10414
|
cancelRecoveryAction,
|