@gpc-cli/core 0.9.31 → 0.9.33
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 +116 -2
- package/dist/index.js +1751 -86
- package/dist/index.js.map +1 -1
- package/package.json +8 -5
package/dist/index.js
CHANGED
|
@@ -563,11 +563,11 @@ import { stat as stat2 } from "fs/promises";
|
|
|
563
563
|
import { PlayApiError } from "@gpc-cli/api";
|
|
564
564
|
|
|
565
565
|
// src/utils/file-validation.ts
|
|
566
|
-
import {
|
|
566
|
+
import { open, stat } from "fs/promises";
|
|
567
567
|
import { extname } from "path";
|
|
568
568
|
var ZIP_MAGIC = Buffer.from([80, 75, 3, 4]);
|
|
569
|
-
var MAX_APK_SIZE =
|
|
570
|
-
var MAX_AAB_SIZE =
|
|
569
|
+
var MAX_APK_SIZE = 1024 * 1024 * 1024;
|
|
570
|
+
var MAX_AAB_SIZE = 2 * 1024 * 1024 * 1024;
|
|
571
571
|
var LARGE_FILE_THRESHOLD = 100 * 1024 * 1024;
|
|
572
572
|
async function validateUploadFile(filePath) {
|
|
573
573
|
const errors = [];
|
|
@@ -594,11 +594,11 @@ async function validateUploadFile(filePath) {
|
|
|
594
594
|
}
|
|
595
595
|
if (fileType === "apk" && sizeBytes > MAX_APK_SIZE) {
|
|
596
596
|
errors.push(
|
|
597
|
-
`APK exceeds
|
|
597
|
+
`APK exceeds 1 GB limit (${formatSize(sizeBytes)}). Consider using AAB format instead.`
|
|
598
598
|
);
|
|
599
599
|
}
|
|
600
600
|
if (fileType === "aab" && sizeBytes > MAX_AAB_SIZE) {
|
|
601
|
-
errors.push(`AAB exceeds
|
|
601
|
+
errors.push(`AAB exceeds 2 GB limit (${formatSize(sizeBytes)}).`);
|
|
602
602
|
}
|
|
603
603
|
if (sizeBytes > LARGE_FILE_THRESHOLD && errors.length === 0) {
|
|
604
604
|
warnings.push(
|
|
@@ -606,16 +606,20 @@ async function validateUploadFile(filePath) {
|
|
|
606
606
|
);
|
|
607
607
|
}
|
|
608
608
|
if (sizeBytes > 0) {
|
|
609
|
+
let fh;
|
|
609
610
|
try {
|
|
610
|
-
|
|
611
|
-
const
|
|
612
|
-
|
|
611
|
+
fh = await open(filePath, "r");
|
|
612
|
+
const buf = Buffer.alloc(4);
|
|
613
|
+
await fh.read(buf, 0, 4, 0);
|
|
614
|
+
if (!buf.equals(ZIP_MAGIC)) {
|
|
613
615
|
errors.push(
|
|
614
616
|
"File does not have valid ZIP magic bytes (PK\\x03\\x04). Both AAB and APK files must be valid ZIP archives."
|
|
615
617
|
);
|
|
616
618
|
}
|
|
617
619
|
} catch {
|
|
618
620
|
errors.push("Unable to read file header for validation");
|
|
621
|
+
} finally {
|
|
622
|
+
await fh?.close();
|
|
619
623
|
}
|
|
620
624
|
}
|
|
621
625
|
return {
|
|
@@ -640,6 +644,41 @@ function formatSize(bytes) {
|
|
|
640
644
|
}
|
|
641
645
|
|
|
642
646
|
// src/commands/releases.ts
|
|
647
|
+
async function withRetryOnConflict(client, packageName, operation) {
|
|
648
|
+
const edit = await client.edits.insert(packageName);
|
|
649
|
+
try {
|
|
650
|
+
return await operation(edit);
|
|
651
|
+
} catch (error) {
|
|
652
|
+
const isConflict = error instanceof PlayApiError && error.statusCode === 409;
|
|
653
|
+
if (!isConflict) {
|
|
654
|
+
await client.edits.delete(packageName, edit.id).catch(() => {
|
|
655
|
+
});
|
|
656
|
+
throw error;
|
|
657
|
+
}
|
|
658
|
+
await client.edits.delete(packageName, edit.id).catch(() => {
|
|
659
|
+
});
|
|
660
|
+
const freshEdit = await client.edits.insert(packageName);
|
|
661
|
+
try {
|
|
662
|
+
return await operation(freshEdit);
|
|
663
|
+
} catch (retryError) {
|
|
664
|
+
await client.edits.delete(packageName, freshEdit.id).catch(() => {
|
|
665
|
+
});
|
|
666
|
+
throw retryError;
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
function warnIfEditExpiring(edit) {
|
|
671
|
+
if (!edit.expiryTimeSeconds) return;
|
|
672
|
+
const expiryMs = Number(edit.expiryTimeSeconds) * 1e3;
|
|
673
|
+
const remainingMs = expiryMs - Date.now();
|
|
674
|
+
if (remainingMs < 5 * 60 * 1e3 && remainingMs > 0) {
|
|
675
|
+
const minutes = Math.round(remainingMs / 6e4);
|
|
676
|
+
process.emitWarning?.(
|
|
677
|
+
`Edit session expires in ~${minutes} minute${minutes !== 1 ? "s" : ""}. Long uploads may fail. Consider starting a fresh operation.`,
|
|
678
|
+
"EditExpiryWarning"
|
|
679
|
+
);
|
|
680
|
+
}
|
|
681
|
+
}
|
|
643
682
|
async function uploadRelease(client, packageName, filePath, options) {
|
|
644
683
|
const validation = await validateUploadFile(filePath);
|
|
645
684
|
if (options.dryRun) {
|
|
@@ -691,9 +730,15 @@ ${validation.errors.join("\n")}`,
|
|
|
691
730
|
}
|
|
692
731
|
if (options.onProgress) options.onProgress(0, fileSize);
|
|
693
732
|
const edit = await client.edits.insert(packageName);
|
|
733
|
+
warnIfEditExpiring(edit);
|
|
694
734
|
try {
|
|
695
|
-
const bundle = await client.bundles.upload(packageName, edit.id, filePath
|
|
696
|
-
|
|
735
|
+
const bundle = await client.bundles.upload(packageName, edit.id, filePath, {
|
|
736
|
+
...options.uploadOptions,
|
|
737
|
+
onProgress: (event) => {
|
|
738
|
+
if (options.onProgress) options.onProgress(event.bytesUploaded, event.totalBytes);
|
|
739
|
+
if (options.onUploadProgress) options.onUploadProgress(event);
|
|
740
|
+
}
|
|
741
|
+
});
|
|
697
742
|
if (options.mappingFile) {
|
|
698
743
|
await client.deobfuscation.upload(
|
|
699
744
|
packageName,
|
|
@@ -748,8 +793,15 @@ async function getReleasesStatus(client, packageName, trackFilter) {
|
|
|
748
793
|
}
|
|
749
794
|
}
|
|
750
795
|
async function promoteRelease(client, packageName, fromTrack, toTrack, options) {
|
|
751
|
-
|
|
752
|
-
|
|
796
|
+
if (options?.userFraction && (options.userFraction <= 0 || options.userFraction > 1)) {
|
|
797
|
+
throw new GpcError(
|
|
798
|
+
"Rollout percentage must be between 0 and 1 (e.g., 0.1 for 10%)",
|
|
799
|
+
"RELEASE_INVALID_FRACTION",
|
|
800
|
+
2,
|
|
801
|
+
"Use a decimal value like 0.1 for 10%, 0.5 for 50%, or 1.0 for 100%."
|
|
802
|
+
);
|
|
803
|
+
}
|
|
804
|
+
return withRetryOnConflict(client, packageName, async (edit) => {
|
|
753
805
|
const sourceTrack = await client.tracks.get(packageName, edit.id, fromTrack);
|
|
754
806
|
const currentRelease = sourceTrack.releases?.find(
|
|
755
807
|
(r) => r.status === "completed" || r.status === "inProgress"
|
|
@@ -762,14 +814,6 @@ async function promoteRelease(client, packageName, fromTrack, toTrack, options)
|
|
|
762
814
|
`Ensure there is a completed or in-progress release on the "${fromTrack}" track before promoting.`
|
|
763
815
|
);
|
|
764
816
|
}
|
|
765
|
-
if (options?.userFraction && (options.userFraction <= 0 || options.userFraction > 1)) {
|
|
766
|
-
throw new GpcError(
|
|
767
|
-
"Rollout percentage must be between 0 and 1 (e.g., 0.1 for 10%)",
|
|
768
|
-
"RELEASE_INVALID_FRACTION",
|
|
769
|
-
2,
|
|
770
|
-
"Use a decimal value like 0.1 for 10%, 0.5 for 50%, or 1.0 for 100%."
|
|
771
|
-
);
|
|
772
|
-
}
|
|
773
817
|
const release = {
|
|
774
818
|
versionCodes: currentRelease.versionCodes,
|
|
775
819
|
status: options?.userFraction ? "inProgress" : "completed",
|
|
@@ -785,11 +829,7 @@ async function promoteRelease(client, packageName, fromTrack, toTrack, options)
|
|
|
785
829
|
versionCodes: release.versionCodes,
|
|
786
830
|
userFraction: release.userFraction
|
|
787
831
|
};
|
|
788
|
-
}
|
|
789
|
-
await client.edits.delete(packageName, edit.id).catch(() => {
|
|
790
|
-
});
|
|
791
|
-
throw error;
|
|
792
|
-
}
|
|
832
|
+
});
|
|
793
833
|
}
|
|
794
834
|
async function updateRollout(client, packageName, track, action, userFraction) {
|
|
795
835
|
const edit = await client.edits.insert(packageName);
|
|
@@ -929,6 +969,25 @@ async function updateTrackConfig(client, packageName, trackName, config) {
|
|
|
929
969
|
throw error;
|
|
930
970
|
}
|
|
931
971
|
}
|
|
972
|
+
async function fetchReleaseNotes(client, packageName, track) {
|
|
973
|
+
const edit = await client.edits.insert(packageName);
|
|
974
|
+
try {
|
|
975
|
+
const trackData = await client.tracks.get(packageName, edit.id, track);
|
|
976
|
+
const release = trackData.releases?.find((r) => r.status === "completed" || r.status === "inProgress") ?? trackData.releases?.[0];
|
|
977
|
+
if (!release) {
|
|
978
|
+
throw new GpcError(
|
|
979
|
+
`No release found on track "${track}" to copy notes from`,
|
|
980
|
+
"RELEASE_NOT_FOUND",
|
|
981
|
+
1,
|
|
982
|
+
`Ensure there is a release on the "${track}" track.`
|
|
983
|
+
);
|
|
984
|
+
}
|
|
985
|
+
return release.releaseNotes ?? [];
|
|
986
|
+
} finally {
|
|
987
|
+
await client.edits.delete(packageName, edit.id).catch(() => {
|
|
988
|
+
});
|
|
989
|
+
}
|
|
990
|
+
}
|
|
932
991
|
async function diffReleases(client, packageName, fromTrack, toTrack) {
|
|
933
992
|
const edit = await client.edits.insert(packageName);
|
|
934
993
|
try {
|
|
@@ -1132,7 +1191,7 @@ function formatSize2(bytes) {
|
|
|
1132
1191
|
}
|
|
1133
1192
|
|
|
1134
1193
|
// src/utils/fastlane.ts
|
|
1135
|
-
import { readFile
|
|
1194
|
+
import { readFile, writeFile, mkdir, readdir, stat as stat4 } from "fs/promises";
|
|
1136
1195
|
import { join } from "path";
|
|
1137
1196
|
var FILE_MAP = {
|
|
1138
1197
|
"title.txt": "title",
|
|
@@ -1155,9 +1214,9 @@ async function readListingsFromDir(dir) {
|
|
|
1155
1214
|
const listings = [];
|
|
1156
1215
|
if (!await exists(dir)) return listings;
|
|
1157
1216
|
const entries = await readdir(dir);
|
|
1158
|
-
const
|
|
1217
|
+
const SAFE_LANG2 = /^[a-zA-Z]{2,3}(-[a-zA-Z0-9]{2,8})*$/;
|
|
1159
1218
|
for (const lang of entries) {
|
|
1160
|
-
if (!
|
|
1219
|
+
if (!SAFE_LANG2.test(lang)) continue;
|
|
1161
1220
|
const langDir = join(dir, lang);
|
|
1162
1221
|
const langStat = await stat4(langDir);
|
|
1163
1222
|
if (!langStat.isDirectory()) continue;
|
|
@@ -1170,7 +1229,7 @@ async function readListingsFromDir(dir) {
|
|
|
1170
1229
|
for (const [fileName, field] of Object.entries(FILE_MAP)) {
|
|
1171
1230
|
const filePath = join(langDir, fileName);
|
|
1172
1231
|
if (await exists(filePath)) {
|
|
1173
|
-
const content = await
|
|
1232
|
+
const content = await readFile(filePath, "utf-8");
|
|
1174
1233
|
listing[field] = content.trimEnd();
|
|
1175
1234
|
}
|
|
1176
1235
|
}
|
|
@@ -1566,8 +1625,8 @@ var ALL_IMAGE_TYPES = [
|
|
|
1566
1625
|
"tvBanner"
|
|
1567
1626
|
];
|
|
1568
1627
|
async function exportImages(client, packageName, dir, options) {
|
|
1569
|
-
const { mkdir:
|
|
1570
|
-
const { join:
|
|
1628
|
+
const { mkdir: mkdir8, writeFile: writeFile10 } = await import("fs/promises");
|
|
1629
|
+
const { join: join12 } = await import("path");
|
|
1571
1630
|
const edit = await client.edits.insert(packageName);
|
|
1572
1631
|
try {
|
|
1573
1632
|
let languages;
|
|
@@ -1598,12 +1657,12 @@ async function exportImages(client, packageName, dir, options) {
|
|
|
1598
1657
|
const batch = tasks.slice(i, i + concurrency);
|
|
1599
1658
|
const results = await Promise.all(
|
|
1600
1659
|
batch.map(async (task) => {
|
|
1601
|
-
const dirPath =
|
|
1602
|
-
await
|
|
1660
|
+
const dirPath = join12(dir, task.language, task.imageType);
|
|
1661
|
+
await mkdir8(dirPath, { recursive: true });
|
|
1603
1662
|
const response = await fetch(task.url);
|
|
1604
1663
|
const buffer = Buffer.from(await response.arrayBuffer());
|
|
1605
|
-
const filePath =
|
|
1606
|
-
await
|
|
1664
|
+
const filePath = join12(dirPath, `${task.index}.png`);
|
|
1665
|
+
await writeFile10(filePath, buffer);
|
|
1607
1666
|
return buffer.length;
|
|
1608
1667
|
})
|
|
1609
1668
|
);
|
|
@@ -1639,7 +1698,7 @@ async function updateAppDetails(client, packageName, details) {
|
|
|
1639
1698
|
}
|
|
1640
1699
|
|
|
1641
1700
|
// src/commands/migrate.ts
|
|
1642
|
-
import { readdir as readdir2, readFile as
|
|
1701
|
+
import { readdir as readdir2, readFile as readFile2, writeFile as writeFile2, mkdir as mkdir2, access } from "fs/promises";
|
|
1643
1702
|
import { join as join2 } from "path";
|
|
1644
1703
|
var COMPLEX_RUBY_RE = /\b(begin|rescue|ensure|if |unless |case |while |until |for )\b/;
|
|
1645
1704
|
async function fileExists(path) {
|
|
@@ -1679,7 +1738,7 @@ async function detectFastlane(cwd) {
|
|
|
1679
1738
|
}
|
|
1680
1739
|
if (result.hasFastfile) {
|
|
1681
1740
|
try {
|
|
1682
|
-
const content = await
|
|
1741
|
+
const content = await readFile2(fastfilePath, "utf-8");
|
|
1683
1742
|
result.lanes = parseFastfile(content);
|
|
1684
1743
|
if (COMPLEX_RUBY_RE.test(content)) {
|
|
1685
1744
|
result.parseWarnings.push(
|
|
@@ -1692,7 +1751,7 @@ async function detectFastlane(cwd) {
|
|
|
1692
1751
|
}
|
|
1693
1752
|
if (result.hasAppfile) {
|
|
1694
1753
|
try {
|
|
1695
|
-
const content = await
|
|
1754
|
+
const content = await readFile2(appfilePath, "utf-8");
|
|
1696
1755
|
const parsed = parseAppfile(content);
|
|
1697
1756
|
result.packageName = parsed.packageName;
|
|
1698
1757
|
result.jsonKeyPath = parsed.jsonKeyPath;
|
|
@@ -1923,7 +1982,7 @@ function validateSku(sku) {
|
|
|
1923
1982
|
}
|
|
1924
1983
|
|
|
1925
1984
|
// src/utils/release-notes.ts
|
|
1926
|
-
import { readdir as readdir3, readFile as
|
|
1985
|
+
import { readdir as readdir3, readFile as readFile3, stat as stat5 } from "fs/promises";
|
|
1927
1986
|
import { extname as extname3, basename, join as join3 } from "path";
|
|
1928
1987
|
var MAX_NOTES_LENGTH = 500;
|
|
1929
1988
|
async function readReleaseNotesFromDir(dir) {
|
|
@@ -1945,7 +2004,7 @@ async function readReleaseNotesFromDir(dir) {
|
|
|
1945
2004
|
const filePath = join3(dir, entry);
|
|
1946
2005
|
const stats = await stat5(filePath);
|
|
1947
2006
|
if (!stats.isFile()) continue;
|
|
1948
|
-
const text = (await
|
|
2007
|
+
const text = (await readFile3(filePath, "utf-8")).trim();
|
|
1949
2008
|
if (text.length === 0) continue;
|
|
1950
2009
|
notes.push({ language, text });
|
|
1951
2010
|
}
|
|
@@ -2770,8 +2829,8 @@ var METRIC_SET_METRICS = {
|
|
|
2770
2829
|
slowStartRateMetricSet: ["slowStartRate", "distinctUsers"],
|
|
2771
2830
|
slowRenderingRateMetricSet: ["slowRenderingRate", "distinctUsers"],
|
|
2772
2831
|
excessiveWakeupRateMetricSet: ["excessiveWakeupRate", "distinctUsers"],
|
|
2773
|
-
// API requires the weighted variants — base `stuckBackgroundWakelockRate` is not a valid metric
|
|
2774
2832
|
stuckBackgroundWakelockRateMetricSet: [
|
|
2833
|
+
"stuckBackgroundWakelockRate",
|
|
2775
2834
|
"stuckBackgroundWakelockRate7dUserWeighted",
|
|
2776
2835
|
"stuckBackgroundWakelockRate28dUserWeighted",
|
|
2777
2836
|
"distinctUsers"
|
|
@@ -2841,7 +2900,8 @@ async function getVitalsAnr(reporting, packageName, options) {
|
|
|
2841
2900
|
return queryMetric(reporting, packageName, "anrRateMetricSet", options);
|
|
2842
2901
|
}
|
|
2843
2902
|
async function getVitalsStartup(reporting, packageName, options) {
|
|
2844
|
-
|
|
2903
|
+
const opts = options?.dimension ? options : { ...options, dimension: "startType" };
|
|
2904
|
+
return queryMetric(reporting, packageName, "slowStartRateMetricSet", opts);
|
|
2845
2905
|
}
|
|
2846
2906
|
async function getVitalsRendering(reporting, packageName, options) {
|
|
2847
2907
|
return queryMetric(reporting, packageName, "slowRenderingRateMetricSet", options);
|
|
@@ -3013,7 +3073,7 @@ function watchVitalsWithAutoHalt(reporting, packageName, options) {
|
|
|
3013
3073
|
}
|
|
3014
3074
|
|
|
3015
3075
|
// src/commands/iap.ts
|
|
3016
|
-
import { readdir as readdir4, readFile as
|
|
3076
|
+
import { readdir as readdir4, readFile as readFile4 } from "fs/promises";
|
|
3017
3077
|
import { join as join4 } from "path";
|
|
3018
3078
|
import { paginateAll as paginateAll3 } from "@gpc-cli/api";
|
|
3019
3079
|
async function listInAppProducts(client, packageName, options) {
|
|
@@ -3103,7 +3163,7 @@ async function readProductsFromDir(dir) {
|
|
|
3103
3163
|
const jsonFiles = files.filter((f) => f.endsWith(".json"));
|
|
3104
3164
|
const localProducts = [];
|
|
3105
3165
|
for (const file of jsonFiles) {
|
|
3106
|
-
const content = await
|
|
3166
|
+
const content = await readFile4(join4(dir, file), "utf-8");
|
|
3107
3167
|
try {
|
|
3108
3168
|
localProducts.push(JSON.parse(content));
|
|
3109
3169
|
} catch {
|
|
@@ -3409,7 +3469,7 @@ async function deleteGrant(client, developerId, email, packageName) {
|
|
|
3409
3469
|
}
|
|
3410
3470
|
|
|
3411
3471
|
// src/commands/testers.ts
|
|
3412
|
-
import { readFile as
|
|
3472
|
+
import { readFile as readFile5 } from "fs/promises";
|
|
3413
3473
|
async function listTesters(client, packageName, track) {
|
|
3414
3474
|
const edit = await client.edits.insert(packageName);
|
|
3415
3475
|
try {
|
|
@@ -3458,7 +3518,7 @@ async function removeTesters(client, packageName, track, groupEmails) {
|
|
|
3458
3518
|
}
|
|
3459
3519
|
}
|
|
3460
3520
|
async function importTestersFromCsv(client, packageName, track, csvPath) {
|
|
3461
|
-
const content = await
|
|
3521
|
+
const content = await readFile5(csvPath, "utf-8");
|
|
3462
3522
|
const emails = content.split(/[,\n\r]+/).map((e) => e.trim()).filter((e) => e.length > 0 && e.includes("@"));
|
|
3463
3523
|
if (emails.length === 0) {
|
|
3464
3524
|
throw new GpcError(
|
|
@@ -3601,7 +3661,7 @@ async function addRecoveryTargeting(client, packageName, actionId, targeting) {
|
|
|
3601
3661
|
}
|
|
3602
3662
|
|
|
3603
3663
|
// src/commands/data-safety.ts
|
|
3604
|
-
import { readFile as
|
|
3664
|
+
import { readFile as readFile6, writeFile as writeFile3 } from "fs/promises";
|
|
3605
3665
|
async function getDataSafety(client, packageName) {
|
|
3606
3666
|
return client.dataSafety.get(packageName);
|
|
3607
3667
|
}
|
|
@@ -3614,7 +3674,7 @@ async function exportDataSafety(client, packageName, outputPath) {
|
|
|
3614
3674
|
return dataSafety;
|
|
3615
3675
|
}
|
|
3616
3676
|
async function importDataSafety(client, packageName, filePath) {
|
|
3617
|
-
const content = await
|
|
3677
|
+
const content = await readFile6(filePath, "utf-8");
|
|
3618
3678
|
let data;
|
|
3619
3679
|
try {
|
|
3620
3680
|
data = JSON.parse(content);
|
|
@@ -3911,7 +3971,7 @@ function createSpinner(message) {
|
|
|
3911
3971
|
}
|
|
3912
3972
|
|
|
3913
3973
|
// src/utils/train-state.ts
|
|
3914
|
-
import { mkdir as mkdir3, readFile as
|
|
3974
|
+
import { mkdir as mkdir3, readFile as readFile7, writeFile as writeFile4 } from "fs/promises";
|
|
3915
3975
|
import { join as join5 } from "path";
|
|
3916
3976
|
import { getCacheDir } from "@gpc-cli/config";
|
|
3917
3977
|
function stateFile(packageName) {
|
|
@@ -3920,7 +3980,7 @@ function stateFile(packageName) {
|
|
|
3920
3980
|
async function readTrainState(packageName) {
|
|
3921
3981
|
const path = stateFile(packageName);
|
|
3922
3982
|
try {
|
|
3923
|
-
const raw = await
|
|
3983
|
+
const raw = await readFile7(path, "utf-8");
|
|
3924
3984
|
return JSON.parse(raw);
|
|
3925
3985
|
} catch {
|
|
3926
3986
|
return null;
|
|
@@ -4085,7 +4145,7 @@ async function createEnterpriseApp(client, organizationId, app) {
|
|
|
4085
4145
|
}
|
|
4086
4146
|
|
|
4087
4147
|
// src/audit.ts
|
|
4088
|
-
import { appendFile, chmod, mkdir as mkdir4, readFile as
|
|
4148
|
+
import { appendFile, chmod, mkdir as mkdir4, readFile as readFile8, writeFile as writeFile5 } from "fs/promises";
|
|
4089
4149
|
import { join as join6 } from "path";
|
|
4090
4150
|
var auditDir = null;
|
|
4091
4151
|
function initAudit(configDir) {
|
|
@@ -4153,7 +4213,7 @@ async function listAuditEvents(options) {
|
|
|
4153
4213
|
const logPath = join6(auditDir, "audit.log");
|
|
4154
4214
|
let content;
|
|
4155
4215
|
try {
|
|
4156
|
-
content = await
|
|
4216
|
+
content = await readFile8(logPath, "utf-8");
|
|
4157
4217
|
} catch {
|
|
4158
4218
|
return [];
|
|
4159
4219
|
}
|
|
@@ -4191,7 +4251,7 @@ async function clearAuditLog(options) {
|
|
|
4191
4251
|
const logPath = join6(auditDir, "audit.log");
|
|
4192
4252
|
let content;
|
|
4193
4253
|
try {
|
|
4194
|
-
content = await
|
|
4254
|
+
content = await readFile8(logPath, "utf-8");
|
|
4195
4255
|
} catch {
|
|
4196
4256
|
return { deleted: 0, remaining: 0 };
|
|
4197
4257
|
}
|
|
@@ -4278,6 +4338,1599 @@ function safePathWithin(userPath, baseDir) {
|
|
|
4278
4338
|
return resolved;
|
|
4279
4339
|
}
|
|
4280
4340
|
|
|
4341
|
+
// src/commands/init.ts
|
|
4342
|
+
import { mkdir as mkdir5, writeFile as writeFile6, stat as stat7 } from "fs/promises";
|
|
4343
|
+
import { join as join7 } from "path";
|
|
4344
|
+
async function exists2(path) {
|
|
4345
|
+
try {
|
|
4346
|
+
await stat7(path);
|
|
4347
|
+
return true;
|
|
4348
|
+
} catch {
|
|
4349
|
+
return false;
|
|
4350
|
+
}
|
|
4351
|
+
}
|
|
4352
|
+
async function safeWrite(filePath, content, created, skipped, skipExisting) {
|
|
4353
|
+
if (await exists2(filePath)) {
|
|
4354
|
+
if (skipExisting) {
|
|
4355
|
+
skipped.push(filePath);
|
|
4356
|
+
return;
|
|
4357
|
+
}
|
|
4358
|
+
}
|
|
4359
|
+
const dir = filePath.substring(0, filePath.lastIndexOf("/"));
|
|
4360
|
+
await mkdir5(dir, { recursive: true });
|
|
4361
|
+
await writeFile6(filePath, content, "utf-8");
|
|
4362
|
+
created.push(filePath);
|
|
4363
|
+
}
|
|
4364
|
+
async function initProject(options) {
|
|
4365
|
+
const { dir, app, ci } = options;
|
|
4366
|
+
const skipExisting = options.skipExisting !== false;
|
|
4367
|
+
const created = [];
|
|
4368
|
+
const skipped = [];
|
|
4369
|
+
const pkg = app || "com.example.app";
|
|
4370
|
+
const gpcrc = JSON.stringify(
|
|
4371
|
+
{
|
|
4372
|
+
app: pkg,
|
|
4373
|
+
output: "table"
|
|
4374
|
+
},
|
|
4375
|
+
null,
|
|
4376
|
+
2
|
|
4377
|
+
);
|
|
4378
|
+
await safeWrite(join7(dir, ".gpcrc.json"), gpcrc + "\n", created, skipped, skipExisting);
|
|
4379
|
+
const preflightrc = JSON.stringify(
|
|
4380
|
+
{
|
|
4381
|
+
failOn: "error",
|
|
4382
|
+
targetSdkMinimum: 35,
|
|
4383
|
+
maxDownloadSizeMb: 150,
|
|
4384
|
+
allowedPermissions: [],
|
|
4385
|
+
disabledRules: [],
|
|
4386
|
+
severityOverrides: {}
|
|
4387
|
+
},
|
|
4388
|
+
null,
|
|
4389
|
+
2
|
|
4390
|
+
);
|
|
4391
|
+
await safeWrite(
|
|
4392
|
+
join7(dir, ".preflightrc.json"),
|
|
4393
|
+
preflightrc + "\n",
|
|
4394
|
+
created,
|
|
4395
|
+
skipped,
|
|
4396
|
+
skipExisting
|
|
4397
|
+
);
|
|
4398
|
+
const metaDir = join7(dir, "metadata", "android", "en-US");
|
|
4399
|
+
await safeWrite(join7(metaDir, "title.txt"), "", created, skipped, skipExisting);
|
|
4400
|
+
await safeWrite(join7(metaDir, "short_description.txt"), "", created, skipped, skipExisting);
|
|
4401
|
+
await safeWrite(join7(metaDir, "full_description.txt"), "", created, skipped, skipExisting);
|
|
4402
|
+
await safeWrite(join7(metaDir, "video.txt"), "", created, skipped, skipExisting);
|
|
4403
|
+
const ssDir = join7(metaDir, "images", "phoneScreenshots");
|
|
4404
|
+
await mkdir5(ssDir, { recursive: true });
|
|
4405
|
+
await safeWrite(join7(ssDir, ".gitkeep"), "", created, skipped, skipExisting);
|
|
4406
|
+
if (ci === "github") {
|
|
4407
|
+
const workflow = githubActionsTemplate(pkg);
|
|
4408
|
+
const workflowDir = join7(dir, ".github", "workflows");
|
|
4409
|
+
await safeWrite(join7(workflowDir, "gpc-release.yml"), workflow, created, skipped, skipExisting);
|
|
4410
|
+
} else if (ci === "gitlab") {
|
|
4411
|
+
const pipeline = gitlabCiTemplate(pkg);
|
|
4412
|
+
await safeWrite(join7(dir, ".gitlab-ci-gpc.yml"), pipeline, created, skipped, skipExisting);
|
|
4413
|
+
}
|
|
4414
|
+
return { created, skipped };
|
|
4415
|
+
}
|
|
4416
|
+
function githubActionsTemplate(pkg) {
|
|
4417
|
+
return `name: GPC Release
|
|
4418
|
+
on:
|
|
4419
|
+
push:
|
|
4420
|
+
tags: ['v*']
|
|
4421
|
+
|
|
4422
|
+
jobs:
|
|
4423
|
+
release:
|
|
4424
|
+
runs-on: ubuntu-latest
|
|
4425
|
+
env:
|
|
4426
|
+
GPC_SERVICE_ACCOUNT: \${{ secrets.GPC_SERVICE_ACCOUNT }}
|
|
4427
|
+
GPC_APP: ${pkg}
|
|
4428
|
+
steps:
|
|
4429
|
+
- uses: actions/checkout@v4
|
|
4430
|
+
|
|
4431
|
+
- uses: actions/setup-node@v4
|
|
4432
|
+
with:
|
|
4433
|
+
node-version: 20
|
|
4434
|
+
|
|
4435
|
+
- name: Build
|
|
4436
|
+
run: ./gradlew bundleRelease
|
|
4437
|
+
|
|
4438
|
+
- name: Install GPC
|
|
4439
|
+
run: npm install -g @gpc-cli/cli
|
|
4440
|
+
|
|
4441
|
+
- name: Preflight compliance check
|
|
4442
|
+
run: gpc preflight app/build/outputs/bundle/release/app-release.aab --fail-on error
|
|
4443
|
+
|
|
4444
|
+
- name: Upload to internal track
|
|
4445
|
+
run: |
|
|
4446
|
+
gpc releases upload app/build/outputs/bundle/release/app-release.aab \\
|
|
4447
|
+
--track internal \\
|
|
4448
|
+
--json
|
|
4449
|
+
`;
|
|
4450
|
+
}
|
|
4451
|
+
function gitlabCiTemplate(pkg) {
|
|
4452
|
+
return `# GPC Release Pipeline
|
|
4453
|
+
# Add GPC_SERVICE_ACCOUNT as a CI/CD variable (masked, protected)
|
|
4454
|
+
|
|
4455
|
+
stages:
|
|
4456
|
+
- build
|
|
4457
|
+
- preflight
|
|
4458
|
+
- release
|
|
4459
|
+
|
|
4460
|
+
variables:
|
|
4461
|
+
GPC_APP: ${pkg}
|
|
4462
|
+
|
|
4463
|
+
build:
|
|
4464
|
+
stage: build
|
|
4465
|
+
image: gradle:jdk17
|
|
4466
|
+
script:
|
|
4467
|
+
- ./gradlew bundleRelease
|
|
4468
|
+
artifacts:
|
|
4469
|
+
paths:
|
|
4470
|
+
- app/build/outputs/bundle/release/app-release.aab
|
|
4471
|
+
|
|
4472
|
+
preflight:
|
|
4473
|
+
stage: preflight
|
|
4474
|
+
image: node:20
|
|
4475
|
+
script:
|
|
4476
|
+
- npm install -g @gpc-cli/cli
|
|
4477
|
+
- gpc preflight app/build/outputs/bundle/release/app-release.aab --fail-on error --json
|
|
4478
|
+
|
|
4479
|
+
release:
|
|
4480
|
+
stage: release
|
|
4481
|
+
image: node:20
|
|
4482
|
+
script:
|
|
4483
|
+
- npm install -g @gpc-cli/cli
|
|
4484
|
+
- gpc releases upload app/build/outputs/bundle/release/app-release.aab --track internal
|
|
4485
|
+
only:
|
|
4486
|
+
- tags
|
|
4487
|
+
`;
|
|
4488
|
+
}
|
|
4489
|
+
|
|
4490
|
+
// src/preflight/types.ts
|
|
4491
|
+
var SEVERITY_ORDER = {
|
|
4492
|
+
info: 0,
|
|
4493
|
+
warning: 1,
|
|
4494
|
+
error: 2,
|
|
4495
|
+
critical: 3
|
|
4496
|
+
};
|
|
4497
|
+
var DEFAULT_PREFLIGHT_CONFIG = {
|
|
4498
|
+
failOn: "error",
|
|
4499
|
+
targetSdkMinimum: 35,
|
|
4500
|
+
maxDownloadSizeMb: 150,
|
|
4501
|
+
allowedPermissions: [],
|
|
4502
|
+
disabledRules: [],
|
|
4503
|
+
severityOverrides: {}
|
|
4504
|
+
};
|
|
4505
|
+
|
|
4506
|
+
// src/preflight/config.ts
|
|
4507
|
+
import { readFile as readFile9 } from "fs/promises";
|
|
4508
|
+
var VALID_SEVERITIES = /* @__PURE__ */ new Set(["critical", "error", "warning", "info"]);
|
|
4509
|
+
async function loadPreflightConfig(configPath) {
|
|
4510
|
+
const path = configPath || ".preflightrc.json";
|
|
4511
|
+
let raw;
|
|
4512
|
+
try {
|
|
4513
|
+
raw = await readFile9(path, "utf-8");
|
|
4514
|
+
} catch {
|
|
4515
|
+
return { ...DEFAULT_PREFLIGHT_CONFIG };
|
|
4516
|
+
}
|
|
4517
|
+
let parsed;
|
|
4518
|
+
try {
|
|
4519
|
+
parsed = JSON.parse(raw);
|
|
4520
|
+
} catch {
|
|
4521
|
+
throw new Error(`Invalid JSON in ${path}. Check the file for syntax errors.`);
|
|
4522
|
+
}
|
|
4523
|
+
const config = { ...DEFAULT_PREFLIGHT_CONFIG };
|
|
4524
|
+
if (typeof parsed["failOn"] === "string" && VALID_SEVERITIES.has(parsed["failOn"])) {
|
|
4525
|
+
config.failOn = parsed["failOn"];
|
|
4526
|
+
}
|
|
4527
|
+
if (typeof parsed["targetSdkMinimum"] === "number" && parsed["targetSdkMinimum"] > 0) {
|
|
4528
|
+
config.targetSdkMinimum = parsed["targetSdkMinimum"];
|
|
4529
|
+
}
|
|
4530
|
+
if (typeof parsed["maxDownloadSizeMb"] === "number" && parsed["maxDownloadSizeMb"] > 0) {
|
|
4531
|
+
config.maxDownloadSizeMb = parsed["maxDownloadSizeMb"];
|
|
4532
|
+
}
|
|
4533
|
+
if (Array.isArray(parsed["allowedPermissions"])) {
|
|
4534
|
+
config.allowedPermissions = parsed["allowedPermissions"].filter(
|
|
4535
|
+
(v) => typeof v === "string"
|
|
4536
|
+
);
|
|
4537
|
+
}
|
|
4538
|
+
if (Array.isArray(parsed["disabledRules"])) {
|
|
4539
|
+
config.disabledRules = parsed["disabledRules"].filter(
|
|
4540
|
+
(v) => typeof v === "string"
|
|
4541
|
+
);
|
|
4542
|
+
}
|
|
4543
|
+
if (typeof parsed["severityOverrides"] === "object" && parsed["severityOverrides"] !== null) {
|
|
4544
|
+
const overrides = {};
|
|
4545
|
+
for (const [key, val] of Object.entries(
|
|
4546
|
+
parsed["severityOverrides"]
|
|
4547
|
+
)) {
|
|
4548
|
+
if (typeof val === "string" && VALID_SEVERITIES.has(val)) {
|
|
4549
|
+
overrides[key] = val;
|
|
4550
|
+
}
|
|
4551
|
+
}
|
|
4552
|
+
config.severityOverrides = overrides;
|
|
4553
|
+
}
|
|
4554
|
+
return config;
|
|
4555
|
+
}
|
|
4556
|
+
|
|
4557
|
+
// src/preflight/aab-reader.ts
|
|
4558
|
+
import { open as yauzlOpen } from "yauzl";
|
|
4559
|
+
|
|
4560
|
+
// src/preflight/manifest-parser.ts
|
|
4561
|
+
import * as protobuf from "protobufjs";
|
|
4562
|
+
var RESOURCE_IDS = {
|
|
4563
|
+
16842752: "theme",
|
|
4564
|
+
16842753: "label",
|
|
4565
|
+
16842754: "icon",
|
|
4566
|
+
16842755: "name",
|
|
4567
|
+
16842767: "versionCode",
|
|
4568
|
+
16842768: "versionName",
|
|
4569
|
+
16843276: "minSdkVersion",
|
|
4570
|
+
16843376: "targetSdkVersion",
|
|
4571
|
+
16842782: "debuggable",
|
|
4572
|
+
16842786: "permission",
|
|
4573
|
+
16842795: "exported",
|
|
4574
|
+
16843378: "testOnly",
|
|
4575
|
+
16843760: "usesCleartextTraffic",
|
|
4576
|
+
16844010: "extractNativeLibs",
|
|
4577
|
+
16843985: "foregroundServiceType",
|
|
4578
|
+
16843402: "required",
|
|
4579
|
+
16843393: "allowBackup"
|
|
4580
|
+
};
|
|
4581
|
+
function buildXmlSchema() {
|
|
4582
|
+
const root = new protobuf.Root();
|
|
4583
|
+
const ns = root.define("aapt.pb");
|
|
4584
|
+
const Source = new protobuf.Type("Source").add(new protobuf.Field("pathIdx", 1, "uint32")).add(new protobuf.Field("position", 2, "Position"));
|
|
4585
|
+
const Position = new protobuf.Type("Position").add(new protobuf.Field("lineNumber", 1, "uint32")).add(new protobuf.Field("columnNumber", 2, "uint32"));
|
|
4586
|
+
const Primitive = new protobuf.Type("Primitive").add(
|
|
4587
|
+
new protobuf.OneOf("oneofValue").add(new protobuf.Field("intDecimalValue", 6, "int32")).add(new protobuf.Field("intHexadecimalValue", 7, "uint32")).add(new protobuf.Field("booleanValue", 8, "bool")).add(new protobuf.Field("floatValue", 11, "float"))
|
|
4588
|
+
);
|
|
4589
|
+
const Reference = new protobuf.Type("Reference").add(new protobuf.Field("id", 1, "uint32")).add(new protobuf.Field("name", 2, "string"));
|
|
4590
|
+
const Item = new protobuf.Type("Item").add(
|
|
4591
|
+
new protobuf.OneOf("value").add(new protobuf.Field("ref", 1, "Reference")).add(new protobuf.Field("str", 2, "String")).add(new protobuf.Field("prim", 4, "Primitive"))
|
|
4592
|
+
);
|
|
4593
|
+
const StringMsg = new protobuf.Type("String").add(new protobuf.Field("value", 1, "string"));
|
|
4594
|
+
const XmlAttribute = new protobuf.Type("XmlAttribute").add(new protobuf.Field("namespaceUri", 1, "string")).add(new protobuf.Field("name", 2, "string")).add(new protobuf.Field("value", 3, "string")).add(new protobuf.Field("source", 4, "Source")).add(new protobuf.Field("resourceId", 5, "uint32")).add(new protobuf.Field("compiledItem", 6, "Item"));
|
|
4595
|
+
const XmlNamespace = new protobuf.Type("XmlNamespace").add(new protobuf.Field("prefix", 1, "string")).add(new protobuf.Field("uri", 2, "string")).add(new protobuf.Field("source", 3, "Source"));
|
|
4596
|
+
const XmlElement = new protobuf.Type("XmlElement").add(new protobuf.Field("namespaceDeclaration", 1, "XmlNamespace", "repeated")).add(new protobuf.Field("namespaceUri", 2, "string")).add(new protobuf.Field("name", 3, "string")).add(new protobuf.Field("attribute", 4, "XmlAttribute", "repeated")).add(new protobuf.Field("child", 5, "XmlNode", "repeated"));
|
|
4597
|
+
const XmlNode = new protobuf.Type("XmlNode").add(
|
|
4598
|
+
new protobuf.OneOf("node").add(new protobuf.Field("element", 1, "XmlElement")).add(new protobuf.Field("text", 2, "string"))
|
|
4599
|
+
).add(new protobuf.Field("source", 3, "Source"));
|
|
4600
|
+
ns.add(Position);
|
|
4601
|
+
ns.add(Source);
|
|
4602
|
+
ns.add(Primitive);
|
|
4603
|
+
ns.add(Reference);
|
|
4604
|
+
ns.add(StringMsg);
|
|
4605
|
+
ns.add(Item);
|
|
4606
|
+
ns.add(XmlAttribute);
|
|
4607
|
+
ns.add(XmlNamespace);
|
|
4608
|
+
ns.add(XmlElement);
|
|
4609
|
+
ns.add(XmlNode);
|
|
4610
|
+
return root;
|
|
4611
|
+
}
|
|
4612
|
+
var cachedSchema;
|
|
4613
|
+
function getSchema() {
|
|
4614
|
+
if (!cachedSchema) cachedSchema = buildXmlSchema();
|
|
4615
|
+
return cachedSchema;
|
|
4616
|
+
}
|
|
4617
|
+
function decodeManifest(buf) {
|
|
4618
|
+
const root = getSchema();
|
|
4619
|
+
const XmlNode = root.lookupType("aapt.pb.XmlNode");
|
|
4620
|
+
const decoded = XmlNode.decode(buf);
|
|
4621
|
+
if (!decoded.element || decoded.element.name !== "manifest") {
|
|
4622
|
+
throw new Error("Invalid AAB manifest: root element is not <manifest>");
|
|
4623
|
+
}
|
|
4624
|
+
return extractManifestData(decoded.element);
|
|
4625
|
+
}
|
|
4626
|
+
function getAttrValue(attrs, resId) {
|
|
4627
|
+
const attr = attrs.find((a) => a.resourceId === resId);
|
|
4628
|
+
if (!attr) return void 0;
|
|
4629
|
+
const ci = attr.compiledItem;
|
|
4630
|
+
if (ci?.str?.value !== void 0) return ci.str.value;
|
|
4631
|
+
if (ci?.ref?.name !== void 0) return ci.ref.name;
|
|
4632
|
+
return attr.value || void 0;
|
|
4633
|
+
}
|
|
4634
|
+
function getAttrByName(attrs, name) {
|
|
4635
|
+
const attr = attrs.find((a) => a.name === name || RESOURCE_IDS[a.resourceId] === name);
|
|
4636
|
+
if (!attr) return void 0;
|
|
4637
|
+
const ci = attr.compiledItem;
|
|
4638
|
+
if (ci?.str?.value !== void 0) return ci.str.value;
|
|
4639
|
+
if (ci?.ref?.name !== void 0) return ci.ref.name;
|
|
4640
|
+
return attr.value || void 0;
|
|
4641
|
+
}
|
|
4642
|
+
function getBoolAttr(attrs, resId, defaultVal) {
|
|
4643
|
+
const val = getAttrValue(attrs, resId);
|
|
4644
|
+
if (val === void 0) return defaultVal;
|
|
4645
|
+
return val === "true" || val === "1";
|
|
4646
|
+
}
|
|
4647
|
+
function getIntAttr(attrs, resId, defaultVal) {
|
|
4648
|
+
const val = getAttrValue(attrs, resId);
|
|
4649
|
+
if (val === void 0) return defaultVal;
|
|
4650
|
+
const n = parseInt(val, 10);
|
|
4651
|
+
return isNaN(n) ? defaultVal : n;
|
|
4652
|
+
}
|
|
4653
|
+
function getChildren(elem, tagName) {
|
|
4654
|
+
return (elem.child || []).filter((c) => c.element?.name === tagName).map((c) => c.element);
|
|
4655
|
+
}
|
|
4656
|
+
function extractManifestData(manifest) {
|
|
4657
|
+
const attrs = manifest.attribute || [];
|
|
4658
|
+
const packageName = getAttrByName(attrs, "package") || "";
|
|
4659
|
+
const versionCode = getIntAttr(attrs, 16842767, 0);
|
|
4660
|
+
const versionName = getAttrValue(attrs, 16842768) || "";
|
|
4661
|
+
const usesSdkElements = getChildren(manifest, "uses-sdk");
|
|
4662
|
+
const usesSdk = usesSdkElements[0];
|
|
4663
|
+
const minSdk = usesSdk ? getIntAttr(usesSdk.attribute || [], 16843276, 1) : 1;
|
|
4664
|
+
const targetSdk = usesSdk ? getIntAttr(usesSdk.attribute || [], 16843376, minSdk) : minSdk;
|
|
4665
|
+
const permissions = getChildren(manifest, "uses-permission").map((el) => getAttrValue(el.attribute || [], 16842755)).filter((p) => p !== void 0);
|
|
4666
|
+
const features = getChildren(manifest, "uses-feature").map((el) => ({
|
|
4667
|
+
name: getAttrValue(el.attribute || [], 16842755) || "",
|
|
4668
|
+
required: getBoolAttr(el.attribute || [], 16843402, true)
|
|
4669
|
+
}));
|
|
4670
|
+
const appElements = getChildren(manifest, "application");
|
|
4671
|
+
const app = appElements[0];
|
|
4672
|
+
const debuggable = app ? getBoolAttr(app.attribute || [], 16842782, false) : false;
|
|
4673
|
+
const testOnly = getBoolAttr(attrs, 16843378, false);
|
|
4674
|
+
const usesCleartextTraffic = app ? getBoolAttr(app.attribute || [], 16843760, true) : true;
|
|
4675
|
+
const extractNativeLibs = app ? getBoolAttr(app.attribute || [], 16844010, true) : true;
|
|
4676
|
+
const activities = app ? extractComponents(app, "activity") : [];
|
|
4677
|
+
const services = app ? extractComponents(app, "service") : [];
|
|
4678
|
+
const receivers = app ? extractComponents(app, "receiver") : [];
|
|
4679
|
+
const providers = app ? extractComponents(app, "provider") : [];
|
|
4680
|
+
return {
|
|
4681
|
+
packageName,
|
|
4682
|
+
versionCode,
|
|
4683
|
+
versionName,
|
|
4684
|
+
minSdk,
|
|
4685
|
+
targetSdk,
|
|
4686
|
+
debuggable,
|
|
4687
|
+
testOnly,
|
|
4688
|
+
usesCleartextTraffic,
|
|
4689
|
+
extractNativeLibs,
|
|
4690
|
+
permissions,
|
|
4691
|
+
features,
|
|
4692
|
+
activities,
|
|
4693
|
+
services,
|
|
4694
|
+
receivers,
|
|
4695
|
+
providers
|
|
4696
|
+
};
|
|
4697
|
+
}
|
|
4698
|
+
function extractComponents(appElement, tagName) {
|
|
4699
|
+
return getChildren(appElement, tagName).map((el) => {
|
|
4700
|
+
const compAttrs = el.attribute || [];
|
|
4701
|
+
const exportedVal = getAttrValue(compAttrs, 16842795);
|
|
4702
|
+
const hasIntentFilter = getChildren(el, "intent-filter").length > 0;
|
|
4703
|
+
return {
|
|
4704
|
+
name: getAttrValue(compAttrs, 16842755) || "",
|
|
4705
|
+
exported: exportedVal === void 0 ? void 0 : exportedVal === "true" || exportedVal === "1",
|
|
4706
|
+
foregroundServiceType: tagName === "service" ? getAttrValue(compAttrs, 16843985) : void 0,
|
|
4707
|
+
hasIntentFilter
|
|
4708
|
+
};
|
|
4709
|
+
});
|
|
4710
|
+
}
|
|
4711
|
+
|
|
4712
|
+
// src/preflight/aab-reader.ts
|
|
4713
|
+
var MANIFEST_PATH = "base/manifest/AndroidManifest.xml";
|
|
4714
|
+
async function readAab(aabPath) {
|
|
4715
|
+
const { zipfile, entries, manifestBuf } = await openAndScan(aabPath);
|
|
4716
|
+
zipfile.close();
|
|
4717
|
+
if (!manifestBuf) {
|
|
4718
|
+
throw new Error(
|
|
4719
|
+
`AAB is missing ${MANIFEST_PATH}. This does not appear to be a valid Android App Bundle.`
|
|
4720
|
+
);
|
|
4721
|
+
}
|
|
4722
|
+
const manifest = decodeManifest(manifestBuf);
|
|
4723
|
+
return { manifest, entries };
|
|
4724
|
+
}
|
|
4725
|
+
function openAndScan(aabPath) {
|
|
4726
|
+
return new Promise((resolve2, reject) => {
|
|
4727
|
+
yauzlOpen(aabPath, { lazyEntries: true, autoClose: false }, (err, zipfile) => {
|
|
4728
|
+
if (err || !zipfile) {
|
|
4729
|
+
reject(err ?? new Error("Failed to open AAB file"));
|
|
4730
|
+
return;
|
|
4731
|
+
}
|
|
4732
|
+
const entries = [];
|
|
4733
|
+
let manifestBuf = null;
|
|
4734
|
+
let rejected = false;
|
|
4735
|
+
function fail(error) {
|
|
4736
|
+
if (!rejected) {
|
|
4737
|
+
rejected = true;
|
|
4738
|
+
zipfile.close();
|
|
4739
|
+
reject(error);
|
|
4740
|
+
}
|
|
4741
|
+
}
|
|
4742
|
+
zipfile.on("error", fail);
|
|
4743
|
+
zipfile.on("entry", (entry) => {
|
|
4744
|
+
if (rejected) return;
|
|
4745
|
+
const path = entry.fileName;
|
|
4746
|
+
if (!path.endsWith("/")) {
|
|
4747
|
+
entries.push({
|
|
4748
|
+
path,
|
|
4749
|
+
compressedSize: entry.compressedSize,
|
|
4750
|
+
uncompressedSize: entry.uncompressedSize
|
|
4751
|
+
});
|
|
4752
|
+
}
|
|
4753
|
+
if (path === MANIFEST_PATH) {
|
|
4754
|
+
zipfile.openReadStream(entry, (streamErr, stream) => {
|
|
4755
|
+
if (streamErr || !stream) {
|
|
4756
|
+
fail(streamErr ?? new Error("Failed to read manifest entry"));
|
|
4757
|
+
return;
|
|
4758
|
+
}
|
|
4759
|
+
const chunks = [];
|
|
4760
|
+
stream.on("data", (chunk) => chunks.push(chunk));
|
|
4761
|
+
stream.on("error", (e) => fail(e));
|
|
4762
|
+
stream.on("end", () => {
|
|
4763
|
+
manifestBuf = Buffer.concat(chunks);
|
|
4764
|
+
zipfile.readEntry();
|
|
4765
|
+
});
|
|
4766
|
+
});
|
|
4767
|
+
} else {
|
|
4768
|
+
zipfile.readEntry();
|
|
4769
|
+
}
|
|
4770
|
+
});
|
|
4771
|
+
zipfile.on("end", () => {
|
|
4772
|
+
if (!rejected) {
|
|
4773
|
+
resolve2({ zipfile, entries, manifestBuf });
|
|
4774
|
+
}
|
|
4775
|
+
});
|
|
4776
|
+
zipfile.readEntry();
|
|
4777
|
+
});
|
|
4778
|
+
});
|
|
4779
|
+
}
|
|
4780
|
+
|
|
4781
|
+
// src/preflight/scanners/manifest-scanner.ts
|
|
4782
|
+
var manifestScanner = {
|
|
4783
|
+
name: "manifest",
|
|
4784
|
+
description: "Checks AndroidManifest.xml for target SDK, debug flags, and component declarations",
|
|
4785
|
+
requires: ["manifest"],
|
|
4786
|
+
async scan(ctx) {
|
|
4787
|
+
const manifest = ctx.manifest;
|
|
4788
|
+
const findings = [];
|
|
4789
|
+
const minTargetSdk = ctx.config.targetSdkMinimum;
|
|
4790
|
+
if (manifest.targetSdk < minTargetSdk) {
|
|
4791
|
+
findings.push({
|
|
4792
|
+
scanner: "manifest",
|
|
4793
|
+
ruleId: "targetSdk-below-minimum",
|
|
4794
|
+
severity: "critical",
|
|
4795
|
+
title: `targetSdkVersion ${manifest.targetSdk} is below the required ${minTargetSdk}`,
|
|
4796
|
+
message: `Google Play requires targetSdkVersion >= ${minTargetSdk} for new apps and updates. Your app targets API ${manifest.targetSdk}.`,
|
|
4797
|
+
suggestion: `Update targetSdkVersion to ${minTargetSdk} or higher in your build.gradle file.`,
|
|
4798
|
+
policyUrl: "https://developer.android.com/google/play/requirements/target-sdk"
|
|
4799
|
+
});
|
|
4800
|
+
}
|
|
4801
|
+
if (manifest.debuggable) {
|
|
4802
|
+
findings.push({
|
|
4803
|
+
scanner: "manifest",
|
|
4804
|
+
ruleId: "debuggable-true",
|
|
4805
|
+
severity: "critical",
|
|
4806
|
+
title: "App is marked as debuggable",
|
|
4807
|
+
message: 'android:debuggable="true" is set in the manifest. Google Play rejects debuggable release builds.',
|
|
4808
|
+
suggestion: "Remove android:debuggable from your manifest or set it to false. Release builds should never be debuggable."
|
|
4809
|
+
});
|
|
4810
|
+
}
|
|
4811
|
+
if (manifest.testOnly) {
|
|
4812
|
+
findings.push({
|
|
4813
|
+
scanner: "manifest",
|
|
4814
|
+
ruleId: "testOnly-true",
|
|
4815
|
+
severity: "critical",
|
|
4816
|
+
title: "App is marked as testOnly",
|
|
4817
|
+
message: 'android:testOnly="true" is set in the manifest. Google Play rejects testOnly builds.',
|
|
4818
|
+
suggestion: "Remove android:testOnly from your manifest. This flag is only for development builds."
|
|
4819
|
+
});
|
|
4820
|
+
}
|
|
4821
|
+
if (manifest.versionCode <= 0) {
|
|
4822
|
+
findings.push({
|
|
4823
|
+
scanner: "manifest",
|
|
4824
|
+
ruleId: "versionCode-invalid",
|
|
4825
|
+
severity: "error",
|
|
4826
|
+
title: "Invalid versionCode",
|
|
4827
|
+
message: `versionCode is ${manifest.versionCode}. It must be a positive integer.`,
|
|
4828
|
+
suggestion: "Set a valid versionCode in your build.gradle file."
|
|
4829
|
+
});
|
|
4830
|
+
}
|
|
4831
|
+
if (manifest.usesCleartextTraffic && manifest.targetSdk >= 28) {
|
|
4832
|
+
findings.push({
|
|
4833
|
+
scanner: "manifest",
|
|
4834
|
+
ruleId: "cleartext-traffic",
|
|
4835
|
+
severity: "warning",
|
|
4836
|
+
title: "Cleartext HTTP traffic is allowed",
|
|
4837
|
+
message: 'android:usesCleartextTraffic="true" allows unencrypted HTTP connections. This is a security risk.',
|
|
4838
|
+
suggestion: 'Set android:usesCleartextTraffic="false" and use HTTPS. If specific domains need HTTP, use a network security config.',
|
|
4839
|
+
policyUrl: "https://developer.android.com/privacy-and-security/security-config"
|
|
4840
|
+
});
|
|
4841
|
+
}
|
|
4842
|
+
if (manifest.targetSdk >= 31) {
|
|
4843
|
+
const allComponents = [
|
|
4844
|
+
...manifest.activities,
|
|
4845
|
+
...manifest.services,
|
|
4846
|
+
...manifest.receivers,
|
|
4847
|
+
...manifest.providers
|
|
4848
|
+
];
|
|
4849
|
+
for (const comp of allComponents) {
|
|
4850
|
+
if (comp.hasIntentFilter && comp.exported === void 0) {
|
|
4851
|
+
findings.push({
|
|
4852
|
+
scanner: "manifest",
|
|
4853
|
+
ruleId: "missing-exported",
|
|
4854
|
+
severity: "error",
|
|
4855
|
+
title: `Missing android:exported on ${comp.name}`,
|
|
4856
|
+
message: `Component "${comp.name}" has an intent-filter but no android:exported attribute. This is required for apps targeting API 31+.`,
|
|
4857
|
+
suggestion: `Add android:exported="true" or android:exported="false" to the <activity>, <service>, <receiver>, or <provider> declaration for "${comp.name}".`,
|
|
4858
|
+
policyUrl: "https://developer.android.com/about/versions/12/behavior-changes-12#exported"
|
|
4859
|
+
});
|
|
4860
|
+
}
|
|
4861
|
+
}
|
|
4862
|
+
}
|
|
4863
|
+
if (manifest.targetSdk >= 34) {
|
|
4864
|
+
const hasFgsPerm = manifest.permissions.includes("android.permission.FOREGROUND_SERVICE");
|
|
4865
|
+
if (hasFgsPerm) {
|
|
4866
|
+
for (const service of manifest.services) {
|
|
4867
|
+
if (!service.foregroundServiceType) {
|
|
4868
|
+
findings.push({
|
|
4869
|
+
scanner: "manifest",
|
|
4870
|
+
ruleId: "foreground-service-type-missing",
|
|
4871
|
+
severity: "error",
|
|
4872
|
+
title: `Missing foregroundServiceType on ${service.name}`,
|
|
4873
|
+
message: `Service "${service.name}" does not declare android:foregroundServiceType. This is required for apps targeting API 34+.`,
|
|
4874
|
+
suggestion: `Add android:foregroundServiceType to the <service> declaration. Valid types: camera, connectedDevice, dataSync, health, location, mediaPlayback, mediaProcessing, mediaProjection, microphone, phoneCall, remoteMessaging, shortService, specialUse, systemExempted.`,
|
|
4875
|
+
policyUrl: "https://developer.android.com/about/versions/14/changes/fgs-types-required"
|
|
4876
|
+
});
|
|
4877
|
+
}
|
|
4878
|
+
}
|
|
4879
|
+
}
|
|
4880
|
+
}
|
|
4881
|
+
if (manifest.minSdk < 21) {
|
|
4882
|
+
findings.push({
|
|
4883
|
+
scanner: "manifest",
|
|
4884
|
+
ruleId: "minSdk-below-21",
|
|
4885
|
+
severity: "info",
|
|
4886
|
+
title: `minSdkVersion ${manifest.minSdk} is very low`,
|
|
4887
|
+
message: `minSdkVersion ${manifest.minSdk} means your app supports very old devices (pre-Lollipop). This limits split APK support and modern features.`,
|
|
4888
|
+
suggestion: "Consider raising minSdkVersion to 21 or higher to take advantage of modern Android features and better app size optimization."
|
|
4889
|
+
});
|
|
4890
|
+
}
|
|
4891
|
+
return findings;
|
|
4892
|
+
}
|
|
4893
|
+
};
|
|
4894
|
+
|
|
4895
|
+
// src/preflight/scanners/permissions-scanner.ts
|
|
4896
|
+
var RESTRICTED_PERMISSIONS = [
|
|
4897
|
+
// SMS permissions — only for default SMS handler
|
|
4898
|
+
{
|
|
4899
|
+
permission: "android.permission.READ_SMS",
|
|
4900
|
+
severity: "critical",
|
|
4901
|
+
title: "READ_SMS requires declaration form",
|
|
4902
|
+
message: "READ_SMS is restricted to default SMS/phone/assistant handler apps. Google Play requires a Permissions Declaration Form and may reject apps using this permission without approval.",
|
|
4903
|
+
suggestion: "Remove READ_SMS unless your app is a default SMS handler. Use the SMS Retriever API or one-tap SMS consent for OTP verification.",
|
|
4904
|
+
policyUrl: "https://support.google.com/googleplay/android-developer/answer/10208820"
|
|
4905
|
+
},
|
|
4906
|
+
{
|
|
4907
|
+
permission: "android.permission.SEND_SMS",
|
|
4908
|
+
severity: "critical",
|
|
4909
|
+
title: "SEND_SMS requires declaration form",
|
|
4910
|
+
message: "SEND_SMS is restricted to default SMS handler apps.",
|
|
4911
|
+
suggestion: "Remove SEND_SMS unless your app is a default SMS handler.",
|
|
4912
|
+
policyUrl: "https://support.google.com/googleplay/android-developer/answer/10208820"
|
|
4913
|
+
},
|
|
4914
|
+
{
|
|
4915
|
+
permission: "android.permission.RECEIVE_SMS",
|
|
4916
|
+
severity: "critical",
|
|
4917
|
+
title: "RECEIVE_SMS requires declaration form",
|
|
4918
|
+
message: "RECEIVE_SMS is restricted to default SMS handler apps.",
|
|
4919
|
+
suggestion: "Remove RECEIVE_SMS. Use the SMS Retriever API for OTP verification instead.",
|
|
4920
|
+
policyUrl: "https://support.google.com/googleplay/android-developer/answer/10208820"
|
|
4921
|
+
},
|
|
4922
|
+
{
|
|
4923
|
+
permission: "android.permission.RECEIVE_MMS",
|
|
4924
|
+
severity: "critical",
|
|
4925
|
+
title: "RECEIVE_MMS requires declaration form",
|
|
4926
|
+
message: "RECEIVE_MMS is restricted to default SMS handler apps.",
|
|
4927
|
+
suggestion: "Remove RECEIVE_MMS unless your app is a default SMS handler.",
|
|
4928
|
+
policyUrl: "https://support.google.com/googleplay/android-developer/answer/10208820"
|
|
4929
|
+
},
|
|
4930
|
+
{
|
|
4931
|
+
permission: "android.permission.RECEIVE_WAP_PUSH",
|
|
4932
|
+
severity: "critical",
|
|
4933
|
+
title: "RECEIVE_WAP_PUSH requires declaration form",
|
|
4934
|
+
message: "RECEIVE_WAP_PUSH is restricted to default SMS handler apps.",
|
|
4935
|
+
suggestion: "Remove RECEIVE_WAP_PUSH unless your app is a default SMS handler.",
|
|
4936
|
+
policyUrl: "https://support.google.com/googleplay/android-developer/answer/10208820"
|
|
4937
|
+
},
|
|
4938
|
+
// Call log permissions
|
|
4939
|
+
{
|
|
4940
|
+
permission: "android.permission.READ_CALL_LOG",
|
|
4941
|
+
severity: "critical",
|
|
4942
|
+
title: "READ_CALL_LOG requires declaration form",
|
|
4943
|
+
message: "READ_CALL_LOG is restricted to default phone/assistant handler apps.",
|
|
4944
|
+
suggestion: "Remove READ_CALL_LOG unless your app is a default phone or assistant handler.",
|
|
4945
|
+
policyUrl: "https://support.google.com/googleplay/android-developer/answer/10208820"
|
|
4946
|
+
},
|
|
4947
|
+
{
|
|
4948
|
+
permission: "android.permission.WRITE_CALL_LOG",
|
|
4949
|
+
severity: "critical",
|
|
4950
|
+
title: "WRITE_CALL_LOG requires declaration form",
|
|
4951
|
+
message: "WRITE_CALL_LOG is restricted to default phone handler apps.",
|
|
4952
|
+
suggestion: "Remove WRITE_CALL_LOG unless your app is a default phone handler.",
|
|
4953
|
+
policyUrl: "https://support.google.com/googleplay/android-developer/answer/10208820"
|
|
4954
|
+
},
|
|
4955
|
+
{
|
|
4956
|
+
permission: "android.permission.PROCESS_OUTGOING_CALLS",
|
|
4957
|
+
severity: "critical",
|
|
4958
|
+
title: "PROCESS_OUTGOING_CALLS requires declaration form",
|
|
4959
|
+
message: "PROCESS_OUTGOING_CALLS is restricted to default phone handler apps.",
|
|
4960
|
+
suggestion: "Remove PROCESS_OUTGOING_CALLS unless your app is a default phone handler.",
|
|
4961
|
+
policyUrl: "https://support.google.com/googleplay/android-developer/answer/10208820"
|
|
4962
|
+
},
|
|
4963
|
+
// Broad visibility
|
|
4964
|
+
{
|
|
4965
|
+
permission: "android.permission.QUERY_ALL_PACKAGES",
|
|
4966
|
+
severity: "error",
|
|
4967
|
+
title: "QUERY_ALL_PACKAGES requires justification",
|
|
4968
|
+
message: "QUERY_ALL_PACKAGES grants broad package visibility. Google Play requires justification and may reject apps using this without approval.",
|
|
4969
|
+
suggestion: "Replace with targeted <queries> elements in your manifest to declare specific packages you need to interact with.",
|
|
4970
|
+
policyUrl: "https://support.google.com/googleplay/android-developer/answer/10158779"
|
|
4971
|
+
},
|
|
4972
|
+
// All files access
|
|
4973
|
+
{
|
|
4974
|
+
permission: "android.permission.MANAGE_EXTERNAL_STORAGE",
|
|
4975
|
+
severity: "error",
|
|
4976
|
+
title: "MANAGE_EXTERNAL_STORAGE (All Files Access) requires declaration form",
|
|
4977
|
+
message: "All Files Access is restricted to file managers, backup apps, antivirus, and document management apps.",
|
|
4978
|
+
suggestion: "Use scoped storage APIs or the Storage Access Framework (SAF) instead. Only use MANAGE_EXTERNAL_STORAGE if your app's core functionality requires broad file access.",
|
|
4979
|
+
policyUrl: "https://support.google.com/googleplay/android-developer/answer/10467955"
|
|
4980
|
+
},
|
|
4981
|
+
// Background location
|
|
4982
|
+
{
|
|
4983
|
+
permission: "android.permission.ACCESS_BACKGROUND_LOCATION",
|
|
4984
|
+
severity: "error",
|
|
4985
|
+
title: "ACCESS_BACKGROUND_LOCATION requires declaration and review",
|
|
4986
|
+
message: "Background location access requires a Permissions Declaration Form, privacy policy, and video demonstration. Extended review times apply.",
|
|
4987
|
+
suggestion: "Use foreground location with a foreground service instead. Only use background location if it is essential to your app's core functionality.",
|
|
4988
|
+
policyUrl: "https://support.google.com/googleplay/android-developer/answer/9799150"
|
|
4989
|
+
},
|
|
4990
|
+
// Photo/video permissions (May 2025 enforcement)
|
|
4991
|
+
{
|
|
4992
|
+
permission: "android.permission.READ_MEDIA_IMAGES",
|
|
4993
|
+
severity: "error",
|
|
4994
|
+
title: "READ_MEDIA_IMAGES requires declaration or photo picker",
|
|
4995
|
+
message: "Photo/Video Permissions policy requires either an approved declaration form or use of the Android photo picker for one-time image access.",
|
|
4996
|
+
suggestion: "Use the Android photo picker (ACTION_PICK_IMAGES) for profile pictures and one-time use. Only declare READ_MEDIA_IMAGES if your app's core functionality requires broad gallery access.",
|
|
4997
|
+
policyUrl: "https://support.google.com/googleplay/android-developer/answer/14115180"
|
|
4998
|
+
},
|
|
4999
|
+
{
|
|
5000
|
+
permission: "android.permission.READ_MEDIA_VIDEO",
|
|
5001
|
+
severity: "error",
|
|
5002
|
+
title: "READ_MEDIA_VIDEO requires declaration or photo picker",
|
|
5003
|
+
message: "Photo/Video Permissions policy requires either an approved declaration form or use of the Android photo picker for one-time video access.",
|
|
5004
|
+
suggestion: "Use the Android photo picker for one-time video selection. Only declare READ_MEDIA_VIDEO if your app's core functionality requires broad video access.",
|
|
5005
|
+
policyUrl: "https://support.google.com/googleplay/android-developer/answer/14115180"
|
|
5006
|
+
},
|
|
5007
|
+
// Install packages
|
|
5008
|
+
{
|
|
5009
|
+
permission: "android.permission.REQUEST_INSTALL_PACKAGES",
|
|
5010
|
+
severity: "error",
|
|
5011
|
+
title: "REQUEST_INSTALL_PACKAGES requires justification",
|
|
5012
|
+
message: "REQUEST_INSTALL_PACKAGES is restricted to apps whose core purpose is installing other packages.",
|
|
5013
|
+
suggestion: "Remove REQUEST_INSTALL_PACKAGES unless your app is an app store, package manager, or OTA updater.",
|
|
5014
|
+
policyUrl: "https://support.google.com/googleplay/android-developer/answer/12085295"
|
|
5015
|
+
},
|
|
5016
|
+
// Exact alarm
|
|
5017
|
+
{
|
|
5018
|
+
permission: "android.permission.USE_EXACT_ALARM",
|
|
5019
|
+
severity: "warning",
|
|
5020
|
+
title: "USE_EXACT_ALARM is restricted",
|
|
5021
|
+
message: "USE_EXACT_ALARM is only for alarm, timer, and calendar apps. Google Play may reject apps using this without justification.",
|
|
5022
|
+
suggestion: "Use SCHEDULE_EXACT_ALARM instead if possible, or remove exact alarm usage if your app does not need precise timing.",
|
|
5023
|
+
policyUrl: "https://developer.android.com/about/versions/14/changes/schedule-exact-alarms"
|
|
5024
|
+
},
|
|
5025
|
+
// Full-screen intent
|
|
5026
|
+
{
|
|
5027
|
+
permission: "android.permission.USE_FULL_SCREEN_INTENT",
|
|
5028
|
+
severity: "warning",
|
|
5029
|
+
title: "USE_FULL_SCREEN_INTENT requires declaration",
|
|
5030
|
+
message: "Full-screen intents are restricted to alarm and calling apps on Android 14+. A declaration form is required.",
|
|
5031
|
+
suggestion: "Remove USE_FULL_SCREEN_INTENT unless your app is an alarm clock or calling app.",
|
|
5032
|
+
policyUrl: "https://support.google.com/googleplay/android-developer/answer/13392821"
|
|
5033
|
+
},
|
|
5034
|
+
// Accessibility service
|
|
5035
|
+
{
|
|
5036
|
+
permission: "android.permission.BIND_ACCESSIBILITY_SERVICE",
|
|
5037
|
+
severity: "error",
|
|
5038
|
+
title: "BIND_ACCESSIBILITY_SERVICE requires declaration and justification",
|
|
5039
|
+
message: "Accessibility services must support users with disabilities. A declaration form and detailed justification are required.",
|
|
5040
|
+
suggestion: "Only use BIND_ACCESSIBILITY_SERVICE if your app genuinely assists users with disabilities. Misuse leads to rejection.",
|
|
5041
|
+
policyUrl: "https://support.google.com/googleplay/android-developer/answer/10964491"
|
|
5042
|
+
},
|
|
5043
|
+
// VPN
|
|
5044
|
+
{
|
|
5045
|
+
permission: "android.permission.BIND_VPN_SERVICE",
|
|
5046
|
+
severity: "error",
|
|
5047
|
+
title: "BIND_VPN_SERVICE is restricted to VPN apps",
|
|
5048
|
+
message: "BIND_VPN_SERVICE is only for apps whose core functionality is providing VPN services.",
|
|
5049
|
+
suggestion: "Remove BIND_VPN_SERVICE unless your app is a VPN provider.",
|
|
5050
|
+
policyUrl: "https://support.google.com/googleplay/android-developer/answer/9888170"
|
|
5051
|
+
}
|
|
5052
|
+
];
|
|
5053
|
+
var RESTRICTED_MAP = new Map(RESTRICTED_PERMISSIONS.map((r) => [r.permission, r]));
|
|
5054
|
+
var permissionsScanner = {
|
|
5055
|
+
name: "permissions",
|
|
5056
|
+
description: "Audits declared permissions against Google Play restricted permissions policies",
|
|
5057
|
+
requires: ["manifest"],
|
|
5058
|
+
async scan(ctx) {
|
|
5059
|
+
const manifest = ctx.manifest;
|
|
5060
|
+
const findings = [];
|
|
5061
|
+
const allowed = new Set(ctx.config.allowedPermissions);
|
|
5062
|
+
for (const perm of manifest.permissions) {
|
|
5063
|
+
if (allowed.has(perm)) continue;
|
|
5064
|
+
const restriction = RESTRICTED_MAP.get(perm);
|
|
5065
|
+
if (restriction) {
|
|
5066
|
+
findings.push({
|
|
5067
|
+
scanner: "permissions",
|
|
5068
|
+
ruleId: `restricted-${perm.split(".").pop()?.toLowerCase() || perm}`,
|
|
5069
|
+
severity: restriction.severity,
|
|
5070
|
+
title: restriction.title,
|
|
5071
|
+
message: restriction.message,
|
|
5072
|
+
suggestion: restriction.suggestion,
|
|
5073
|
+
policyUrl: restriction.policyUrl
|
|
5074
|
+
});
|
|
5075
|
+
}
|
|
5076
|
+
}
|
|
5077
|
+
const dataPermissions = [
|
|
5078
|
+
{ perm: "android.permission.ACCESS_FINE_LOCATION", data: "precise location" },
|
|
5079
|
+
{ perm: "android.permission.ACCESS_COARSE_LOCATION", data: "approximate location" },
|
|
5080
|
+
{ perm: "android.permission.READ_CONTACTS", data: "contacts" },
|
|
5081
|
+
{ perm: "android.permission.CAMERA", data: "photos/videos via camera" },
|
|
5082
|
+
{ perm: "android.permission.RECORD_AUDIO", data: "audio recordings" },
|
|
5083
|
+
{ perm: "android.permission.READ_CALENDAR", data: "calendar events" },
|
|
5084
|
+
{ perm: "android.permission.BODY_SENSORS", data: "health/fitness data" },
|
|
5085
|
+
{ perm: "android.permission.ACTIVITY_RECOGNITION", data: "physical activity" }
|
|
5086
|
+
];
|
|
5087
|
+
const collectedData = [];
|
|
5088
|
+
for (const { perm, data } of dataPermissions) {
|
|
5089
|
+
if (manifest.permissions.includes(perm)) {
|
|
5090
|
+
collectedData.push(data);
|
|
5091
|
+
}
|
|
5092
|
+
}
|
|
5093
|
+
if (collectedData.length > 0) {
|
|
5094
|
+
findings.push({
|
|
5095
|
+
scanner: "permissions",
|
|
5096
|
+
ruleId: "data-safety-reminder",
|
|
5097
|
+
severity: "info",
|
|
5098
|
+
title: "Data Safety declaration reminder",
|
|
5099
|
+
message: `Your app declares permissions that imply collecting: ${collectedData.join(", ")}. Ensure your Data Safety form in Play Console accurately reflects this data collection.`,
|
|
5100
|
+
suggestion: "Review your Data Safety declaration at Play Console > Policy > App content > Data safety.",
|
|
5101
|
+
policyUrl: "https://support.google.com/googleplay/android-developer/answer/10787469"
|
|
5102
|
+
});
|
|
5103
|
+
}
|
|
5104
|
+
return findings;
|
|
5105
|
+
}
|
|
5106
|
+
};
|
|
5107
|
+
|
|
5108
|
+
// src/preflight/scanners/native-libs-scanner.ts
|
|
5109
|
+
var KNOWN_ABIS = ["arm64-v8a", "armeabi-v7a", "x86", "x86_64"];
|
|
5110
|
+
var LIB_PATH_RE = /^(?:[^/]+\/)?lib\/([^/]+)\/[^/]+\.so$/;
|
|
5111
|
+
var nativeLibsScanner = {
|
|
5112
|
+
name: "native-libs",
|
|
5113
|
+
description: "Checks native library architectures for 64-bit compliance",
|
|
5114
|
+
requires: ["zipEntries"],
|
|
5115
|
+
async scan(ctx) {
|
|
5116
|
+
const entries = ctx.zipEntries;
|
|
5117
|
+
const findings = [];
|
|
5118
|
+
const abisFound = /* @__PURE__ */ new Set();
|
|
5119
|
+
let totalNativeSize = 0;
|
|
5120
|
+
for (const entry of entries) {
|
|
5121
|
+
const match = LIB_PATH_RE.exec(entry.path);
|
|
5122
|
+
if (match) {
|
|
5123
|
+
abisFound.add(match[1]);
|
|
5124
|
+
totalNativeSize += entry.uncompressedSize;
|
|
5125
|
+
}
|
|
5126
|
+
}
|
|
5127
|
+
if (abisFound.size === 0) {
|
|
5128
|
+
return findings;
|
|
5129
|
+
}
|
|
5130
|
+
const has32Arm = abisFound.has("armeabi-v7a");
|
|
5131
|
+
const has64Arm = abisFound.has("arm64-v8a");
|
|
5132
|
+
const has32x86 = abisFound.has("x86");
|
|
5133
|
+
const has64x86 = abisFound.has("x86_64");
|
|
5134
|
+
if (has32Arm && !has64Arm) {
|
|
5135
|
+
findings.push({
|
|
5136
|
+
scanner: "native-libs",
|
|
5137
|
+
ruleId: "missing-arm64",
|
|
5138
|
+
severity: "critical",
|
|
5139
|
+
title: "Missing arm64-v8a native libraries",
|
|
5140
|
+
message: "App includes armeabi-v7a (32-bit ARM) native libraries but is missing arm64-v8a (64-bit ARM). Google Play requires 64-bit support for all apps with native code.",
|
|
5141
|
+
suggestion: "Build your native libraries for arm64-v8a. In build.gradle: ndk { abiFilters 'armeabi-v7a', 'arm64-v8a' }",
|
|
5142
|
+
policyUrl: "https://developer.android.com/google/play/requirements/64-bit"
|
|
5143
|
+
});
|
|
5144
|
+
}
|
|
5145
|
+
if (has32x86 && !has64x86) {
|
|
5146
|
+
findings.push({
|
|
5147
|
+
scanner: "native-libs",
|
|
5148
|
+
ruleId: "missing-x86_64",
|
|
5149
|
+
severity: "warning",
|
|
5150
|
+
title: "Missing x86_64 native libraries",
|
|
5151
|
+
message: "App includes x86 (32-bit) native libraries but is missing x86_64 (64-bit). While ARM is required, x86_64 is recommended for emulator and Chromebook support.",
|
|
5152
|
+
suggestion: "Add x86_64 to your ABI filters if you support x86: ndk { abiFilters 'x86', 'x86_64' }",
|
|
5153
|
+
policyUrl: "https://developer.android.com/google/play/requirements/64-bit"
|
|
5154
|
+
});
|
|
5155
|
+
}
|
|
5156
|
+
const detectedAbis = KNOWN_ABIS.filter((abi) => abisFound.has(abi));
|
|
5157
|
+
const unknownAbis = [...abisFound].filter(
|
|
5158
|
+
(abi) => !KNOWN_ABIS.includes(abi)
|
|
5159
|
+
);
|
|
5160
|
+
const abiList = [...detectedAbis, ...unknownAbis].join(", ");
|
|
5161
|
+
const sizeMb = (totalNativeSize / (1024 * 1024)).toFixed(1);
|
|
5162
|
+
findings.push({
|
|
5163
|
+
scanner: "native-libs",
|
|
5164
|
+
ruleId: "native-libs-summary",
|
|
5165
|
+
severity: "info",
|
|
5166
|
+
title: `Native libraries: ${abiList}`,
|
|
5167
|
+
message: `Found native libraries for ${abisFound.size} architecture(s): ${abiList}. Total uncompressed size: ${sizeMb} MB.`
|
|
5168
|
+
});
|
|
5169
|
+
if (totalNativeSize > 150 * 1024 * 1024) {
|
|
5170
|
+
findings.push({
|
|
5171
|
+
scanner: "native-libs",
|
|
5172
|
+
ruleId: "native-libs-large",
|
|
5173
|
+
severity: "warning",
|
|
5174
|
+
title: "Large native libraries",
|
|
5175
|
+
message: `Native libraries total ${sizeMb} MB (uncompressed). This significantly increases download size.`,
|
|
5176
|
+
suggestion: "Consider using Android App Bundles to deliver only the required ABI per device. Review if all native libraries are necessary."
|
|
5177
|
+
});
|
|
5178
|
+
}
|
|
5179
|
+
return findings;
|
|
5180
|
+
}
|
|
5181
|
+
};
|
|
5182
|
+
|
|
5183
|
+
// src/preflight/scanners/metadata-scanner.ts
|
|
5184
|
+
import { readdir as readdir5, stat as stat8, readFile as readFile10 } from "fs/promises";
|
|
5185
|
+
import { join as join8 } from "path";
|
|
5186
|
+
var SAFE_LANG = /^[a-zA-Z]{2,3}(-[a-zA-Z0-9]{2,8})*$/;
|
|
5187
|
+
var FILE_MAP2 = {
|
|
5188
|
+
"title.txt": "title",
|
|
5189
|
+
"short_description.txt": "shortDescription",
|
|
5190
|
+
"full_description.txt": "fullDescription",
|
|
5191
|
+
"video.txt": "video"
|
|
5192
|
+
};
|
|
5193
|
+
var SCREENSHOT_DIRS = [
|
|
5194
|
+
"phoneScreenshots",
|
|
5195
|
+
"sevenInchScreenshots",
|
|
5196
|
+
"tenInchScreenshots",
|
|
5197
|
+
"tvScreenshots",
|
|
5198
|
+
"wearScreenshots"
|
|
5199
|
+
];
|
|
5200
|
+
var MIN_PHONE_SCREENSHOTS = 2;
|
|
5201
|
+
var RECOMMENDED_PHONE_SCREENSHOTS = 4;
|
|
5202
|
+
var metadataScanner = {
|
|
5203
|
+
name: "metadata",
|
|
5204
|
+
description: "Checks store listing metadata for character limits, required fields, and screenshots",
|
|
5205
|
+
requires: ["metadataDir"],
|
|
5206
|
+
async scan(ctx) {
|
|
5207
|
+
const dir = ctx.metadataDir;
|
|
5208
|
+
const findings = [];
|
|
5209
|
+
let entries;
|
|
5210
|
+
try {
|
|
5211
|
+
entries = await readdir5(dir);
|
|
5212
|
+
} catch {
|
|
5213
|
+
findings.push({
|
|
5214
|
+
scanner: "metadata",
|
|
5215
|
+
ruleId: "metadata-dir-not-found",
|
|
5216
|
+
severity: "error",
|
|
5217
|
+
title: "Metadata directory not found",
|
|
5218
|
+
message: `Cannot read metadata directory: ${dir}`,
|
|
5219
|
+
suggestion: "Check the path to your metadata directory. Expected Fastlane format: <dir>/<lang>/title.txt, short_description.txt, etc."
|
|
5220
|
+
});
|
|
5221
|
+
return findings;
|
|
5222
|
+
}
|
|
5223
|
+
const locales = entries.filter((e) => SAFE_LANG.test(e));
|
|
5224
|
+
if (locales.length === 0) {
|
|
5225
|
+
findings.push({
|
|
5226
|
+
scanner: "metadata",
|
|
5227
|
+
ruleId: "no-locales-found",
|
|
5228
|
+
severity: "error",
|
|
5229
|
+
title: "No locale directories found",
|
|
5230
|
+
message: `No valid locale directories found in ${dir}. Expected subdirectories like en-US/, fr-FR/, etc.`,
|
|
5231
|
+
suggestion: "Create locale directories with listing files: <dir>/en-US/title.txt"
|
|
5232
|
+
});
|
|
5233
|
+
return findings;
|
|
5234
|
+
}
|
|
5235
|
+
for (const lang of locales) {
|
|
5236
|
+
const langDir = join8(dir, lang);
|
|
5237
|
+
const langStat = await stat8(langDir).catch(() => null);
|
|
5238
|
+
if (!langStat?.isDirectory()) continue;
|
|
5239
|
+
const fields = {};
|
|
5240
|
+
for (const [fileName, field] of Object.entries(FILE_MAP2)) {
|
|
5241
|
+
const filePath = join8(langDir, fileName);
|
|
5242
|
+
try {
|
|
5243
|
+
const content = await readFile10(filePath, "utf-8");
|
|
5244
|
+
fields[field] = content.trimEnd();
|
|
5245
|
+
} catch {
|
|
5246
|
+
}
|
|
5247
|
+
}
|
|
5248
|
+
const lintResult = lintListing(lang, fields, DEFAULT_LIMITS);
|
|
5249
|
+
for (const field of lintResult.fields) {
|
|
5250
|
+
if (field.status === "over") {
|
|
5251
|
+
findings.push({
|
|
5252
|
+
scanner: "metadata",
|
|
5253
|
+
ruleId: `listing-${field.field}-over-limit`,
|
|
5254
|
+
severity: "error",
|
|
5255
|
+
title: `${lang}: ${field.field} exceeds ${field.limit} character limit`,
|
|
5256
|
+
message: `${field.field} is ${field.chars} characters (limit: ${field.limit}). Google Play will reject this listing.`,
|
|
5257
|
+
suggestion: `Shorten ${field.field} to ${field.limit} characters or fewer.`
|
|
5258
|
+
});
|
|
5259
|
+
} else if (field.status === "warn") {
|
|
5260
|
+
findings.push({
|
|
5261
|
+
scanner: "metadata",
|
|
5262
|
+
ruleId: `listing-${field.field}-near-limit`,
|
|
5263
|
+
severity: "info",
|
|
5264
|
+
title: `${lang}: ${field.field} is ${field.pct}% of limit`,
|
|
5265
|
+
message: `${field.field} is ${field.chars}/${field.limit} characters (${field.pct}%).`
|
|
5266
|
+
});
|
|
5267
|
+
}
|
|
5268
|
+
}
|
|
5269
|
+
if (!fields["title"]?.trim()) {
|
|
5270
|
+
findings.push({
|
|
5271
|
+
scanner: "metadata",
|
|
5272
|
+
ruleId: "listing-missing-title",
|
|
5273
|
+
severity: "error",
|
|
5274
|
+
title: `${lang}: Missing title`,
|
|
5275
|
+
message: `No title.txt found or file is empty for locale ${lang}.`,
|
|
5276
|
+
suggestion: "Create a title.txt file with your app name (max 30 characters)."
|
|
5277
|
+
});
|
|
5278
|
+
}
|
|
5279
|
+
let totalScreenshots = 0;
|
|
5280
|
+
let phoneScreenshots = 0;
|
|
5281
|
+
for (const ssDir of SCREENSHOT_DIRS) {
|
|
5282
|
+
const ssPath = join8(langDir, "images", ssDir);
|
|
5283
|
+
try {
|
|
5284
|
+
const ssEntries = await readdir5(ssPath);
|
|
5285
|
+
const imageFiles = ssEntries.filter((f) => /\.(png|jpe?g|webp)$/i.test(f));
|
|
5286
|
+
totalScreenshots += imageFiles.length;
|
|
5287
|
+
if (ssDir === "phoneScreenshots") {
|
|
5288
|
+
phoneScreenshots = imageFiles.length;
|
|
5289
|
+
}
|
|
5290
|
+
} catch {
|
|
5291
|
+
}
|
|
5292
|
+
}
|
|
5293
|
+
if (phoneScreenshots < MIN_PHONE_SCREENSHOTS && totalScreenshots === 0) {
|
|
5294
|
+
findings.push({
|
|
5295
|
+
scanner: "metadata",
|
|
5296
|
+
ruleId: "listing-no-screenshots",
|
|
5297
|
+
severity: "warning",
|
|
5298
|
+
title: `${lang}: No screenshots found`,
|
|
5299
|
+
message: `No screenshot images found for locale ${lang}. Google Play requires at least 2 phone screenshots.`,
|
|
5300
|
+
suggestion: `Add PNG or JPEG screenshots to ${lang}/images/phoneScreenshots/`,
|
|
5301
|
+
policyUrl: "https://support.google.com/googleplay/android-developer/answer/9866151"
|
|
5302
|
+
});
|
|
5303
|
+
} else if (phoneScreenshots < RECOMMENDED_PHONE_SCREENSHOTS && phoneScreenshots > 0) {
|
|
5304
|
+
findings.push({
|
|
5305
|
+
scanner: "metadata",
|
|
5306
|
+
ruleId: "listing-few-screenshots",
|
|
5307
|
+
severity: "info",
|
|
5308
|
+
title: `${lang}: Only ${phoneScreenshots} phone screenshot(s)`,
|
|
5309
|
+
message: `Found ${phoneScreenshots} phone screenshot(s). Google recommends at least ${RECOMMENDED_PHONE_SCREENSHOTS} for better store presence.`
|
|
5310
|
+
});
|
|
5311
|
+
}
|
|
5312
|
+
}
|
|
5313
|
+
const defaultLang = locales.includes("en-US") ? "en-US" : locales[0];
|
|
5314
|
+
const privacyPath = join8(dir, defaultLang, "privacy_policy_url.txt");
|
|
5315
|
+
try {
|
|
5316
|
+
const url = await readFile10(privacyPath, "utf-8");
|
|
5317
|
+
if (!url.trim()) throw new Error("empty");
|
|
5318
|
+
} catch {
|
|
5319
|
+
findings.push({
|
|
5320
|
+
scanner: "metadata",
|
|
5321
|
+
ruleId: "listing-no-privacy-policy",
|
|
5322
|
+
severity: "warning",
|
|
5323
|
+
title: "No privacy policy URL",
|
|
5324
|
+
message: "No privacy_policy_url.txt found in metadata. A privacy policy is required for most apps on Google Play.",
|
|
5325
|
+
suggestion: `Create ${defaultLang}/privacy_policy_url.txt with a link to your privacy policy.`,
|
|
5326
|
+
policyUrl: "https://support.google.com/googleplay/android-developer/answer/9859455"
|
|
5327
|
+
});
|
|
5328
|
+
}
|
|
5329
|
+
findings.push({
|
|
5330
|
+
scanner: "metadata",
|
|
5331
|
+
ruleId: "metadata-summary",
|
|
5332
|
+
severity: "info",
|
|
5333
|
+
title: `${locales.length} locale(s) found`,
|
|
5334
|
+
message: `Scanned metadata for: ${locales.join(", ")}`
|
|
5335
|
+
});
|
|
5336
|
+
return findings;
|
|
5337
|
+
}
|
|
5338
|
+
};
|
|
5339
|
+
|
|
5340
|
+
// src/preflight/scanners/secrets-scanner.ts
|
|
5341
|
+
import { readFile as readFile11 } from "fs/promises";
|
|
5342
|
+
|
|
5343
|
+
// src/preflight/scan-files.ts
|
|
5344
|
+
import { readdir as readdir6, stat as stat9 } from "fs/promises";
|
|
5345
|
+
import { join as join9, extname as extname4 } from "path";
|
|
5346
|
+
var DEFAULT_SKIP_DIRS = /* @__PURE__ */ new Set([
|
|
5347
|
+
".git",
|
|
5348
|
+
"node_modules",
|
|
5349
|
+
"build",
|
|
5350
|
+
"dist",
|
|
5351
|
+
".gradle",
|
|
5352
|
+
"__pycache__",
|
|
5353
|
+
".idea",
|
|
5354
|
+
".vscode",
|
|
5355
|
+
"vendor"
|
|
5356
|
+
]);
|
|
5357
|
+
async function collectSourceFiles(dir, extensions, skipDirs = DEFAULT_SKIP_DIRS, maxDepth = 10) {
|
|
5358
|
+
if (maxDepth <= 0) return [];
|
|
5359
|
+
const files = [];
|
|
5360
|
+
let entries;
|
|
5361
|
+
try {
|
|
5362
|
+
entries = await readdir6(dir);
|
|
5363
|
+
} catch {
|
|
5364
|
+
return files;
|
|
5365
|
+
}
|
|
5366
|
+
for (const entry of entries) {
|
|
5367
|
+
if (skipDirs.has(entry)) continue;
|
|
5368
|
+
const fullPath = join9(dir, entry);
|
|
5369
|
+
const s = await stat9(fullPath).catch(() => null);
|
|
5370
|
+
if (!s) continue;
|
|
5371
|
+
if (s.isDirectory()) {
|
|
5372
|
+
const sub = await collectSourceFiles(fullPath, extensions, skipDirs, maxDepth - 1);
|
|
5373
|
+
files.push(...sub);
|
|
5374
|
+
} else if (s.isFile()) {
|
|
5375
|
+
const ext = extname4(entry).toLowerCase();
|
|
5376
|
+
if (extensions.has(ext) || entry.endsWith(".gradle.kts")) {
|
|
5377
|
+
files.push(fullPath);
|
|
5378
|
+
}
|
|
5379
|
+
}
|
|
5380
|
+
}
|
|
5381
|
+
return files;
|
|
5382
|
+
}
|
|
5383
|
+
|
|
5384
|
+
// src/preflight/scanners/secrets-scanner.ts
|
|
5385
|
+
var SECRET_PATTERNS = [
|
|
5386
|
+
{
|
|
5387
|
+
ruleId: "secret-aws-key",
|
|
5388
|
+
name: "AWS Access Key",
|
|
5389
|
+
pattern: /AKIA[0-9A-Z]{16}/,
|
|
5390
|
+
severity: "critical",
|
|
5391
|
+
suggestion: "Use environment variables or AWS Secrets Manager. Never hardcode AWS credentials."
|
|
5392
|
+
},
|
|
5393
|
+
{
|
|
5394
|
+
ruleId: "secret-google-api-key",
|
|
5395
|
+
name: "Google API Key",
|
|
5396
|
+
pattern: /AIza[0-9A-Za-z\-_]{35}/,
|
|
5397
|
+
severity: "critical",
|
|
5398
|
+
suggestion: "Move Google API keys to local.properties or environment variables. Restrict keys in Google Cloud Console."
|
|
5399
|
+
},
|
|
5400
|
+
{
|
|
5401
|
+
ruleId: "secret-stripe-key",
|
|
5402
|
+
name: "Stripe Secret Key",
|
|
5403
|
+
pattern: /sk_live_[0-9a-zA-Z]{24,}/,
|
|
5404
|
+
severity: "critical",
|
|
5405
|
+
suggestion: "Never ship Stripe secret keys in client code. Use your backend server for Stripe API calls."
|
|
5406
|
+
},
|
|
5407
|
+
{
|
|
5408
|
+
ruleId: "secret-stripe-restricted",
|
|
5409
|
+
name: "Stripe Restricted Key",
|
|
5410
|
+
pattern: /rk_live_[0-9a-zA-Z]{24,}/,
|
|
5411
|
+
severity: "critical",
|
|
5412
|
+
suggestion: "Stripe restricted keys should not be in client code. Use server-side integration."
|
|
5413
|
+
},
|
|
5414
|
+
{
|
|
5415
|
+
ruleId: "secret-private-key",
|
|
5416
|
+
name: "Private Key",
|
|
5417
|
+
pattern: /-----BEGIN\s+(RSA |EC |DSA |OPENSSH )?PRIVATE KEY-----/,
|
|
5418
|
+
severity: "critical",
|
|
5419
|
+
suggestion: "Remove private keys from source code. Store them in a secure key management system."
|
|
5420
|
+
},
|
|
5421
|
+
{
|
|
5422
|
+
ruleId: "secret-firebase-key",
|
|
5423
|
+
name: "Firebase API Key in code",
|
|
5424
|
+
pattern: /["']AIza[0-9A-Za-z\-_]{35}["']/,
|
|
5425
|
+
severity: "warning",
|
|
5426
|
+
suggestion: "Firebase API keys in client code are normal for google-services.json, but verify they are restricted in Google Cloud Console."
|
|
5427
|
+
},
|
|
5428
|
+
{
|
|
5429
|
+
ruleId: "secret-generic-token",
|
|
5430
|
+
name: "Generic API Token",
|
|
5431
|
+
pattern: /(?:api[_-]?key|api[_-]?secret|auth[_-]?token|access[_-]?token)\s*[:=]\s*["'][a-zA-Z0-9\-_]{20,}["']/i,
|
|
5432
|
+
severity: "warning",
|
|
5433
|
+
suggestion: "Avoid hardcoding tokens. Use BuildConfig fields, environment variables, or a secrets manager."
|
|
5434
|
+
}
|
|
5435
|
+
];
|
|
5436
|
+
var SCAN_EXTENSIONS = /* @__PURE__ */ new Set([
|
|
5437
|
+
".ts",
|
|
5438
|
+
".js",
|
|
5439
|
+
".tsx",
|
|
5440
|
+
".jsx",
|
|
5441
|
+
".kt",
|
|
5442
|
+
".java",
|
|
5443
|
+
".xml",
|
|
5444
|
+
".json",
|
|
5445
|
+
".properties",
|
|
5446
|
+
".yaml",
|
|
5447
|
+
".yml",
|
|
5448
|
+
".gradle"
|
|
5449
|
+
]);
|
|
5450
|
+
var secretsScanner = {
|
|
5451
|
+
name: "secrets",
|
|
5452
|
+
description: "Scans source code for hardcoded credentials and API keys",
|
|
5453
|
+
requires: ["sourceDir"],
|
|
5454
|
+
async scan(ctx) {
|
|
5455
|
+
const dir = ctx.sourceDir;
|
|
5456
|
+
const findings = [];
|
|
5457
|
+
const files = await collectSourceFiles(dir, SCAN_EXTENSIONS);
|
|
5458
|
+
for (const filePath of files) {
|
|
5459
|
+
let content;
|
|
5460
|
+
try {
|
|
5461
|
+
content = await readFile11(filePath, "utf-8");
|
|
5462
|
+
} catch {
|
|
5463
|
+
continue;
|
|
5464
|
+
}
|
|
5465
|
+
const lines = content.split("\n");
|
|
5466
|
+
for (let i = 0; i < lines.length; i++) {
|
|
5467
|
+
const line = lines[i];
|
|
5468
|
+
for (const pattern of SECRET_PATTERNS) {
|
|
5469
|
+
if (pattern.pattern.test(line)) {
|
|
5470
|
+
const relativePath = filePath.startsWith(dir) ? filePath.slice(dir.length + 1) : filePath;
|
|
5471
|
+
findings.push({
|
|
5472
|
+
scanner: "secrets",
|
|
5473
|
+
ruleId: pattern.ruleId,
|
|
5474
|
+
severity: pattern.severity,
|
|
5475
|
+
title: `${pattern.name} found in ${relativePath}:${i + 1}`,
|
|
5476
|
+
message: `Potential ${pattern.name} detected at ${relativePath} line ${i + 1}.`,
|
|
5477
|
+
suggestion: pattern.suggestion
|
|
5478
|
+
});
|
|
5479
|
+
break;
|
|
5480
|
+
}
|
|
5481
|
+
}
|
|
5482
|
+
}
|
|
5483
|
+
}
|
|
5484
|
+
return findings;
|
|
5485
|
+
}
|
|
5486
|
+
};
|
|
5487
|
+
|
|
5488
|
+
// src/preflight/scanners/billing-scanner.ts
|
|
5489
|
+
import { readFile as readFile12 } from "fs/promises";
|
|
5490
|
+
var BILLING_PATTERNS = [
|
|
5491
|
+
{
|
|
5492
|
+
ruleId: "billing-stripe-sdk",
|
|
5493
|
+
name: "Stripe SDK",
|
|
5494
|
+
pattern: /(?:com\.stripe|@stripe\/|stripe-android|StripeSdk)/,
|
|
5495
|
+
message: "Stripe SDK detected. Google Play requires Play Billing for in-app purchases of digital goods.",
|
|
5496
|
+
suggestion: "If selling digital goods, use Google Play Billing Library. Stripe is only allowed for physical goods, services, or out-of-app purchases."
|
|
5497
|
+
},
|
|
5498
|
+
{
|
|
5499
|
+
ruleId: "billing-braintree-sdk",
|
|
5500
|
+
name: "Braintree SDK",
|
|
5501
|
+
pattern: /(?:com\.braintreepayments|braintree-android)/,
|
|
5502
|
+
message: "Braintree SDK detected. Google Play requires Play Billing for digital in-app purchases.",
|
|
5503
|
+
suggestion: "Use Google Play Billing Library for digital goods. Braintree is only allowed for physical goods and services."
|
|
5504
|
+
},
|
|
5505
|
+
{
|
|
5506
|
+
ruleId: "billing-paypal-sdk",
|
|
5507
|
+
name: "PayPal SDK",
|
|
5508
|
+
pattern: /(?:com\.paypal|paypal-android)/,
|
|
5509
|
+
message: "PayPal SDK detected. Google Play requires Play Billing for digital in-app purchases.",
|
|
5510
|
+
suggestion: "Use Google Play Billing Library for digital goods. PayPal is allowed for physical goods only."
|
|
5511
|
+
},
|
|
5512
|
+
{
|
|
5513
|
+
ruleId: "billing-razorpay-sdk",
|
|
5514
|
+
name: "Razorpay SDK",
|
|
5515
|
+
pattern: /(?:com\.razorpay)/,
|
|
5516
|
+
message: "Razorpay SDK detected. If used for digital goods, this may violate Google Play billing policy.",
|
|
5517
|
+
suggestion: "Ensure Razorpay is only used for physical goods/services. Digital goods require Play Billing."
|
|
5518
|
+
},
|
|
5519
|
+
{
|
|
5520
|
+
ruleId: "billing-checkout-sdk",
|
|
5521
|
+
name: "Alternative checkout SDK",
|
|
5522
|
+
pattern: /(?:com\.adyen|com\.checkout|com\.square\.sdk)/,
|
|
5523
|
+
message: "Alternative payment SDK detected. Google Play requires Play Billing for digital goods.",
|
|
5524
|
+
suggestion: "Verify this payment SDK is only used for physical goods or services, not digital content."
|
|
5525
|
+
}
|
|
5526
|
+
];
|
|
5527
|
+
var SCAN_EXTENSIONS2 = /* @__PURE__ */ new Set([
|
|
5528
|
+
".kt",
|
|
5529
|
+
".java",
|
|
5530
|
+
".xml",
|
|
5531
|
+
".gradle",
|
|
5532
|
+
".ts",
|
|
5533
|
+
".js",
|
|
5534
|
+
".tsx",
|
|
5535
|
+
".jsx",
|
|
5536
|
+
".json"
|
|
5537
|
+
]);
|
|
5538
|
+
var billingScanner = {
|
|
5539
|
+
name: "billing",
|
|
5540
|
+
description: "Detects non-Play billing SDKs that may violate Google Play billing policy",
|
|
5541
|
+
requires: ["sourceDir"],
|
|
5542
|
+
async scan(ctx) {
|
|
5543
|
+
const dir = ctx.sourceDir;
|
|
5544
|
+
const findings = [];
|
|
5545
|
+
const detectedSdks = /* @__PURE__ */ new Set();
|
|
5546
|
+
const files = await collectSourceFiles(dir, SCAN_EXTENSIONS2);
|
|
5547
|
+
for (const filePath of files) {
|
|
5548
|
+
let content;
|
|
5549
|
+
try {
|
|
5550
|
+
content = await readFile12(filePath, "utf-8");
|
|
5551
|
+
} catch {
|
|
5552
|
+
continue;
|
|
5553
|
+
}
|
|
5554
|
+
for (const bp of BILLING_PATTERNS) {
|
|
5555
|
+
if (detectedSdks.has(bp.ruleId)) continue;
|
|
5556
|
+
if (bp.pattern.test(content)) {
|
|
5557
|
+
const relativePath = filePath.startsWith(dir) ? filePath.slice(dir.length + 1) : filePath;
|
|
5558
|
+
detectedSdks.add(bp.ruleId);
|
|
5559
|
+
findings.push({
|
|
5560
|
+
scanner: "billing",
|
|
5561
|
+
ruleId: bp.ruleId,
|
|
5562
|
+
severity: "warning",
|
|
5563
|
+
title: `${bp.name} detected`,
|
|
5564
|
+
message: `${bp.message} Found in ${relativePath}.`,
|
|
5565
|
+
suggestion: bp.suggestion,
|
|
5566
|
+
policyUrl: "https://support.google.com/googleplay/android-developer/answer/10281818"
|
|
5567
|
+
});
|
|
5568
|
+
}
|
|
5569
|
+
}
|
|
5570
|
+
}
|
|
5571
|
+
return findings;
|
|
5572
|
+
}
|
|
5573
|
+
};
|
|
5574
|
+
|
|
5575
|
+
// src/preflight/scanners/privacy-scanner.ts
|
|
5576
|
+
import { readFile as readFile13 } from "fs/promises";
|
|
5577
|
+
var TRACKING_SDKS = [
|
|
5578
|
+
{
|
|
5579
|
+
name: "Facebook SDK",
|
|
5580
|
+
pattern: /(?:com\.facebook\.sdk|com\.facebook\.android|FacebookSdk\.sdkInitialize)/i
|
|
5581
|
+
},
|
|
5582
|
+
{ name: "Adjust SDK", pattern: /(?:com\.adjust\.sdk|AdjustConfig|AdjustEvent)/i },
|
|
5583
|
+
{
|
|
5584
|
+
name: "AppsFlyer SDK",
|
|
5585
|
+
pattern: /(?:com\.appsflyer|AppsFlyerLib|AppsFlyerConversionListener)/i
|
|
5586
|
+
},
|
|
5587
|
+
{ name: "Amplitude SDK", pattern: /(?:com\.amplitude|AmplitudeClient|@amplitude\/analytics)/i },
|
|
5588
|
+
{ name: "Mixpanel SDK", pattern: /(?:com\.mixpanel|MixpanelAPI|@mixpanel)/i },
|
|
5589
|
+
{ name: "Branch SDK", pattern: /(?:io\.branch\.referral|Branch\.getInstance)/i },
|
|
5590
|
+
{ name: "CleverTap SDK", pattern: /(?:com\.clevertap|CleverTapAPI)/i },
|
|
5591
|
+
{ name: "Singular SDK", pattern: /(?:com\.singular\.sdk|SingularConfig)/i }
|
|
5592
|
+
];
|
|
5593
|
+
var SCAN_EXTENSIONS3 = /* @__PURE__ */ new Set([
|
|
5594
|
+
".kt",
|
|
5595
|
+
".java",
|
|
5596
|
+
".xml",
|
|
5597
|
+
".gradle",
|
|
5598
|
+
".ts",
|
|
5599
|
+
".js",
|
|
5600
|
+
".tsx",
|
|
5601
|
+
".jsx",
|
|
5602
|
+
".json"
|
|
5603
|
+
]);
|
|
5604
|
+
var privacyScanner = {
|
|
5605
|
+
name: "privacy",
|
|
5606
|
+
description: "Detects tracking SDKs and data collection patterns for Data Safety compliance",
|
|
5607
|
+
requires: ["sourceDir"],
|
|
5608
|
+
async scan(ctx) {
|
|
5609
|
+
const dir = ctx.sourceDir;
|
|
5610
|
+
const findings = [];
|
|
5611
|
+
const detectedSdks = /* @__PURE__ */ new Set();
|
|
5612
|
+
const files = await collectSourceFiles(dir, SCAN_EXTENSIONS3);
|
|
5613
|
+
for (const filePath of files) {
|
|
5614
|
+
let content;
|
|
5615
|
+
try {
|
|
5616
|
+
content = await readFile13(filePath, "utf-8");
|
|
5617
|
+
} catch {
|
|
5618
|
+
continue;
|
|
5619
|
+
}
|
|
5620
|
+
for (const sdk of TRACKING_SDKS) {
|
|
5621
|
+
if (detectedSdks.has(sdk.name)) continue;
|
|
5622
|
+
if (sdk.pattern.test(content)) {
|
|
5623
|
+
detectedSdks.add(sdk.name);
|
|
5624
|
+
const relativePath = filePath.startsWith(dir) ? filePath.slice(dir.length + 1) : filePath;
|
|
5625
|
+
findings.push({
|
|
5626
|
+
scanner: "privacy",
|
|
5627
|
+
ruleId: `tracking-${sdk.name.toLowerCase().replace(/\s+/g, "-")}`,
|
|
5628
|
+
severity: "warning",
|
|
5629
|
+
title: `${sdk.name} detected`,
|
|
5630
|
+
message: `${sdk.name} found in ${relativePath}. This SDK typically collects analytics or attribution data that must be declared in your Data Safety form.`,
|
|
5631
|
+
suggestion: "Ensure your Data Safety declaration accurately lists all data types collected by this SDK.",
|
|
5632
|
+
policyUrl: "https://support.google.com/googleplay/android-developer/answer/10787469"
|
|
5633
|
+
});
|
|
5634
|
+
}
|
|
5635
|
+
}
|
|
5636
|
+
if (content.includes("AD_ID") || content.includes("ADVERTISING_ID") || content.includes("AdvertisingIdClient")) {
|
|
5637
|
+
if (!detectedSdks.has("_ad_id")) {
|
|
5638
|
+
detectedSdks.add("_ad_id");
|
|
5639
|
+
findings.push({
|
|
5640
|
+
scanner: "privacy",
|
|
5641
|
+
ruleId: "advertising-id-usage",
|
|
5642
|
+
severity: "warning",
|
|
5643
|
+
title: "Advertising ID usage detected",
|
|
5644
|
+
message: "Your app appears to access the Advertising ID. This must be declared in your Data Safety form under 'Device or other IDs'.",
|
|
5645
|
+
suggestion: "Declare Advertising ID collection in Play Console > Data safety. If your app targets children, Advertising ID usage is restricted.",
|
|
5646
|
+
policyUrl: "https://support.google.com/googleplay/android-developer/answer/11043825"
|
|
5647
|
+
});
|
|
5648
|
+
}
|
|
5649
|
+
}
|
|
5650
|
+
}
|
|
5651
|
+
if (ctx.manifest) {
|
|
5652
|
+
const dataPermissions = [
|
|
5653
|
+
{ perm: "android.permission.ACCESS_FINE_LOCATION", dataType: "precise location" },
|
|
5654
|
+
{ perm: "android.permission.ACCESS_COARSE_LOCATION", dataType: "approximate location" },
|
|
5655
|
+
{ perm: "android.permission.READ_CONTACTS", dataType: "contacts" },
|
|
5656
|
+
{ perm: "android.permission.CAMERA", dataType: "photos/videos" },
|
|
5657
|
+
{ perm: "android.permission.RECORD_AUDIO", dataType: "audio" },
|
|
5658
|
+
{ perm: "android.permission.READ_CALENDAR", dataType: "calendar" },
|
|
5659
|
+
{ perm: "android.permission.BODY_SENSORS", dataType: "health/fitness data" },
|
|
5660
|
+
{ perm: "android.permission.READ_PHONE_STATE", dataType: "phone state/device ID" }
|
|
5661
|
+
];
|
|
5662
|
+
const collectedTypes = [];
|
|
5663
|
+
for (const { perm, dataType } of dataPermissions) {
|
|
5664
|
+
if (ctx.manifest.permissions.includes(perm)) {
|
|
5665
|
+
collectedTypes.push(dataType);
|
|
5666
|
+
}
|
|
5667
|
+
}
|
|
5668
|
+
if (collectedTypes.length > 0 && detectedSdks.size > 0) {
|
|
5669
|
+
findings.push({
|
|
5670
|
+
scanner: "privacy",
|
|
5671
|
+
ruleId: "data-collection-cross-reference",
|
|
5672
|
+
severity: "info",
|
|
5673
|
+
title: "Data collection cross-reference",
|
|
5674
|
+
message: `Your app requests permissions for: ${collectedTypes.join(", ")}. Combined with ${detectedSdks.size} tracking SDK(s), ensure your Data Safety form declares all collected data types.`,
|
|
5675
|
+
suggestion: "Review your Data Safety form at Play Console > Policy > App content > Data safety.",
|
|
5676
|
+
policyUrl: "https://support.google.com/googleplay/android-developer/answer/10787469"
|
|
5677
|
+
});
|
|
5678
|
+
}
|
|
5679
|
+
}
|
|
5680
|
+
return findings;
|
|
5681
|
+
}
|
|
5682
|
+
};
|
|
5683
|
+
|
|
5684
|
+
// src/preflight/scanners/policy-scanner.ts
|
|
5685
|
+
var policyScanner = {
|
|
5686
|
+
name: "policy",
|
|
5687
|
+
description: "Heuristic checks for Google Play policy compliance (families, financial, health, gambling)",
|
|
5688
|
+
requires: ["manifest"],
|
|
5689
|
+
async scan(ctx) {
|
|
5690
|
+
const manifest = ctx.manifest;
|
|
5691
|
+
const findings = [];
|
|
5692
|
+
const perms = new Set(manifest.permissions);
|
|
5693
|
+
if (manifest.targetSdk >= 28) {
|
|
5694
|
+
const childrenIndicators = [
|
|
5695
|
+
perms.has("android.permission.READ_CONTACTS"),
|
|
5696
|
+
perms.has("android.permission.ACCESS_FINE_LOCATION"),
|
|
5697
|
+
perms.has("android.permission.RECORD_AUDIO")
|
|
5698
|
+
];
|
|
5699
|
+
const hasChildFeatures = manifest.features.some(
|
|
5700
|
+
(f) => f.name.includes("kids") || f.name.includes("children") || f.name.includes("education")
|
|
5701
|
+
);
|
|
5702
|
+
if (hasChildFeatures && childrenIndicators.some(Boolean)) {
|
|
5703
|
+
findings.push({
|
|
5704
|
+
scanner: "policy",
|
|
5705
|
+
ruleId: "policy-families-data-collection",
|
|
5706
|
+
severity: "warning",
|
|
5707
|
+
title: "Potential Families Policy concern",
|
|
5708
|
+
message: "App appears to target children (based on features) but requests sensitive permissions (location, contacts, or audio). Apps in the Families program have strict data collection limits.",
|
|
5709
|
+
suggestion: "Review the Families Policy requirements. Apps for children must minimize data collection and cannot use advertising ID.",
|
|
5710
|
+
policyUrl: "https://support.google.com/googleplay/android-developer/answer/9893335"
|
|
5711
|
+
});
|
|
5712
|
+
}
|
|
5713
|
+
}
|
|
5714
|
+
const financialPerms = [
|
|
5715
|
+
perms.has("android.permission.READ_SMS"),
|
|
5716
|
+
perms.has("android.permission.RECEIVE_SMS"),
|
|
5717
|
+
perms.has("android.permission.BIND_AUTOFILL_SERVICE")
|
|
5718
|
+
];
|
|
5719
|
+
if (financialPerms.filter(Boolean).length >= 2) {
|
|
5720
|
+
findings.push({
|
|
5721
|
+
scanner: "policy",
|
|
5722
|
+
ruleId: "policy-financial-app",
|
|
5723
|
+
severity: "warning",
|
|
5724
|
+
title: "Potential financial app detected",
|
|
5725
|
+
message: "App requests SMS + autofill permissions, common in financial apps. Financial apps must comply with additional disclosure and security requirements.",
|
|
5726
|
+
suggestion: "Ensure your app meets Google Play's financial services policy. Declare appropriate app category in Play Console.",
|
|
5727
|
+
policyUrl: "https://support.google.com/googleplay/android-developer/answer/9876821"
|
|
5728
|
+
});
|
|
5729
|
+
}
|
|
5730
|
+
if (perms.has("android.permission.BODY_SENSORS") || perms.has("android.permission.ACTIVITY_RECOGNITION")) {
|
|
5731
|
+
findings.push({
|
|
5732
|
+
scanner: "policy",
|
|
5733
|
+
ruleId: "policy-health-app",
|
|
5734
|
+
severity: "info",
|
|
5735
|
+
title: "Health/fitness app detected",
|
|
5736
|
+
message: "App requests body sensor or activity recognition permissions. Health apps must comply with health data policies.",
|
|
5737
|
+
suggestion: "Review Google Play's health app policy. Ensure accurate health claims and proper data handling disclosures.",
|
|
5738
|
+
policyUrl: "https://support.google.com/googleplay/android-developer/answer/10787469"
|
|
5739
|
+
});
|
|
5740
|
+
}
|
|
5741
|
+
const ugcIndicators = [
|
|
5742
|
+
perms.has("android.permission.CAMERA"),
|
|
5743
|
+
perms.has("android.permission.RECORD_AUDIO"),
|
|
5744
|
+
perms.has("android.permission.READ_MEDIA_IMAGES")
|
|
5745
|
+
];
|
|
5746
|
+
if (ugcIndicators.filter(Boolean).length >= 2) {
|
|
5747
|
+
findings.push({
|
|
5748
|
+
scanner: "policy",
|
|
5749
|
+
ruleId: "policy-ugc-content",
|
|
5750
|
+
severity: "info",
|
|
5751
|
+
title: "User-generated content indicators",
|
|
5752
|
+
message: "App requests camera + audio/media permissions, suggesting user-generated content. Apps with UGC must have content moderation.",
|
|
5753
|
+
suggestion: "Implement content moderation, reporting mechanisms, and content policies if your app allows user-generated content.",
|
|
5754
|
+
policyUrl: "https://support.google.com/googleplay/android-developer/answer/9876937"
|
|
5755
|
+
});
|
|
5756
|
+
}
|
|
5757
|
+
if (perms.has("android.permission.SYSTEM_ALERT_WINDOW")) {
|
|
5758
|
+
findings.push({
|
|
5759
|
+
scanner: "policy",
|
|
5760
|
+
ruleId: "policy-overlay",
|
|
5761
|
+
severity: "warning",
|
|
5762
|
+
title: "SYSTEM_ALERT_WINDOW (overlay) permission",
|
|
5763
|
+
message: "App requests overlay permission. This is restricted and must be justified. Misuse can lead to rejection.",
|
|
5764
|
+
suggestion: "Only use SYSTEM_ALERT_WINDOW if overlay display is core to your app's functionality."
|
|
5765
|
+
});
|
|
5766
|
+
}
|
|
5767
|
+
return findings;
|
|
5768
|
+
}
|
|
5769
|
+
};
|
|
5770
|
+
|
|
5771
|
+
// src/preflight/scanners/size-scanner.ts
|
|
5772
|
+
var sizeScanner = {
|
|
5773
|
+
name: "size",
|
|
5774
|
+
description: "Analyzes app bundle size and warns on large downloads",
|
|
5775
|
+
requires: ["zipEntries"],
|
|
5776
|
+
async scan(ctx) {
|
|
5777
|
+
const entries = ctx.zipEntries;
|
|
5778
|
+
const findings = [];
|
|
5779
|
+
const maxMb = ctx.config.maxDownloadSizeMb;
|
|
5780
|
+
const totalCompressed = entries.reduce((sum, e) => sum + e.compressedSize, 0);
|
|
5781
|
+
const totalUncompressed = entries.reduce((sum, e) => sum + e.uncompressedSize, 0);
|
|
5782
|
+
const compressedMb = totalCompressed / (1024 * 1024);
|
|
5783
|
+
const uncompressedMb = totalUncompressed / (1024 * 1024);
|
|
5784
|
+
if (compressedMb > maxMb) {
|
|
5785
|
+
findings.push({
|
|
5786
|
+
scanner: "size",
|
|
5787
|
+
ruleId: "size-over-limit",
|
|
5788
|
+
severity: "warning",
|
|
5789
|
+
title: `Download size exceeds ${maxMb} MB`,
|
|
5790
|
+
message: `Compressed size is ${compressedMb.toFixed(1)} MB. Downloads over ${maxMb} MB show a mobile data warning to users, which reduces install rates.`,
|
|
5791
|
+
suggestion: "Use Android App Bundles for split APKs, remove unused resources, enable R8/ProGuard, and compress assets.",
|
|
5792
|
+
policyUrl: "https://developer.android.com/topic/performance/reduce-apk-size"
|
|
5793
|
+
});
|
|
5794
|
+
}
|
|
5795
|
+
const categories = /* @__PURE__ */ new Map();
|
|
5796
|
+
for (const entry of entries) {
|
|
5797
|
+
const cat = detectCategory(entry.path);
|
|
5798
|
+
const existing = categories.get(cat) ?? { compressed: 0, uncompressed: 0, count: 0 };
|
|
5799
|
+
existing.compressed += entry.compressedSize;
|
|
5800
|
+
existing.uncompressed += entry.uncompressedSize;
|
|
5801
|
+
existing.count += 1;
|
|
5802
|
+
categories.set(cat, existing);
|
|
5803
|
+
}
|
|
5804
|
+
const nativeLibs = categories.get("native-libs");
|
|
5805
|
+
if (nativeLibs && nativeLibs.compressed > 50 * 1024 * 1024) {
|
|
5806
|
+
findings.push({
|
|
5807
|
+
scanner: "size",
|
|
5808
|
+
ruleId: "size-large-native",
|
|
5809
|
+
severity: "warning",
|
|
5810
|
+
title: "Large native libraries",
|
|
5811
|
+
message: `Native libraries are ${(nativeLibs.compressed / (1024 * 1024)).toFixed(1)} MB (compressed). This is the largest contributor to download size.`,
|
|
5812
|
+
suggestion: "Review which native libraries are bundled. Consider using dynamic feature modules for optional native code."
|
|
5813
|
+
});
|
|
5814
|
+
}
|
|
5815
|
+
const assets = categories.get("assets");
|
|
5816
|
+
if (assets && assets.compressed > 30 * 1024 * 1024) {
|
|
5817
|
+
findings.push({
|
|
5818
|
+
scanner: "size",
|
|
5819
|
+
ruleId: "size-large-assets",
|
|
5820
|
+
severity: "info",
|
|
5821
|
+
title: "Large assets directory",
|
|
5822
|
+
message: `Assets are ${(assets.compressed / (1024 * 1024)).toFixed(1)} MB (compressed). Consider using Play Asset Delivery for large assets.`,
|
|
5823
|
+
suggestion: "Move large assets to Play Asset Delivery (install-time, fast-follow, or on-demand packs).",
|
|
5824
|
+
policyUrl: "https://developer.android.com/guide/playcore/asset-delivery"
|
|
5825
|
+
});
|
|
5826
|
+
}
|
|
5827
|
+
const breakdown = [...categories.entries()].sort((a, b) => b[1].compressed - a[1].compressed).map(([cat, data]) => `${cat}: ${(data.compressed / (1024 * 1024)).toFixed(1)} MB`).join(", ");
|
|
5828
|
+
findings.push({
|
|
5829
|
+
scanner: "size",
|
|
5830
|
+
ruleId: "size-summary",
|
|
5831
|
+
severity: "info",
|
|
5832
|
+
title: `Total size: ${compressedMb.toFixed(1)} MB compressed, ${uncompressedMb.toFixed(1)} MB uncompressed`,
|
|
5833
|
+
message: `${entries.length} files. Breakdown: ${breakdown}`
|
|
5834
|
+
});
|
|
5835
|
+
return findings;
|
|
5836
|
+
}
|
|
5837
|
+
};
|
|
5838
|
+
function detectCategory(path) {
|
|
5839
|
+
const lower = path.toLowerCase();
|
|
5840
|
+
if (lower.endsWith(".dex") || /\/dex\//.test(lower)) return "dex";
|
|
5841
|
+
if (/\/lib\/[^/]+\/[^/]+\.so$/.test(lower)) return "native-libs";
|
|
5842
|
+
if (/\/res\//.test(lower) || lower.endsWith("/resources.pb") || lower.endsWith("/resources.arsc"))
|
|
5843
|
+
return "resources";
|
|
5844
|
+
if (/\/assets\//.test(lower)) return "assets";
|
|
5845
|
+
if (lower.includes("androidmanifest.xml") || /\/manifest\//.test(lower)) return "manifest";
|
|
5846
|
+
if (lower.startsWith("meta-inf/")) return "signing";
|
|
5847
|
+
return "other";
|
|
5848
|
+
}
|
|
5849
|
+
|
|
5850
|
+
// src/preflight/orchestrator.ts
|
|
5851
|
+
var ALL_SCANNERS = [
|
|
5852
|
+
manifestScanner,
|
|
5853
|
+
permissionsScanner,
|
|
5854
|
+
nativeLibsScanner,
|
|
5855
|
+
metadataScanner,
|
|
5856
|
+
secretsScanner,
|
|
5857
|
+
billingScanner,
|
|
5858
|
+
privacyScanner,
|
|
5859
|
+
policyScanner,
|
|
5860
|
+
sizeScanner
|
|
5861
|
+
];
|
|
5862
|
+
function getAllScannerNames() {
|
|
5863
|
+
return ALL_SCANNERS.map((s) => s.name);
|
|
5864
|
+
}
|
|
5865
|
+
async function runPreflight(options) {
|
|
5866
|
+
const start = Date.now();
|
|
5867
|
+
const fileConfig = await loadPreflightConfig(options.configPath);
|
|
5868
|
+
const config = {
|
|
5869
|
+
...fileConfig,
|
|
5870
|
+
failOn: options.failOn ?? fileConfig.failOn ?? DEFAULT_PREFLIGHT_CONFIG.failOn
|
|
5871
|
+
};
|
|
5872
|
+
const ctx = { config };
|
|
5873
|
+
if (options.aabPath) {
|
|
5874
|
+
ctx.aabPath = options.aabPath;
|
|
5875
|
+
const aab = await readAab(options.aabPath);
|
|
5876
|
+
ctx.manifest = aab.manifest;
|
|
5877
|
+
ctx.zipEntries = aab.entries;
|
|
5878
|
+
}
|
|
5879
|
+
if (options.metadataDir) ctx.metadataDir = options.metadataDir;
|
|
5880
|
+
if (options.sourceDir) ctx.sourceDir = options.sourceDir;
|
|
5881
|
+
const requestedNames = options.scanners ? new Set(options.scanners.map((s) => s.toLowerCase())) : null;
|
|
5882
|
+
const applicableScanners = ALL_SCANNERS.filter((scanner) => {
|
|
5883
|
+
if (requestedNames && !requestedNames.has(scanner.name)) return false;
|
|
5884
|
+
for (const req of scanner.requires) {
|
|
5885
|
+
if (req === "manifest" && !ctx.manifest) return false;
|
|
5886
|
+
if (req === "zipEntries" && !ctx.zipEntries) return false;
|
|
5887
|
+
if (req === "metadataDir" && !ctx.metadataDir) return false;
|
|
5888
|
+
if (req === "sourceDir" && !ctx.sourceDir) return false;
|
|
5889
|
+
}
|
|
5890
|
+
return true;
|
|
5891
|
+
});
|
|
5892
|
+
const settled = await Promise.allSettled(applicableScanners.map((scanner) => scanner.scan(ctx)));
|
|
5893
|
+
let findings = [];
|
|
5894
|
+
for (let i = 0; i < settled.length; i++) {
|
|
5895
|
+
const result = settled[i];
|
|
5896
|
+
if (result.status === "fulfilled") {
|
|
5897
|
+
findings.push(...result.value);
|
|
5898
|
+
} else {
|
|
5899
|
+
const scanner = applicableScanners[i];
|
|
5900
|
+
findings.push({
|
|
5901
|
+
scanner: scanner.name,
|
|
5902
|
+
ruleId: "scanner-error",
|
|
5903
|
+
severity: "error",
|
|
5904
|
+
title: `Scanner "${scanner.name}" failed`,
|
|
5905
|
+
message: result.reason instanceof Error ? result.reason.message : String(result.reason),
|
|
5906
|
+
suggestion: "This scanner encountered an unexpected error. Other scanners still ran."
|
|
5907
|
+
});
|
|
5908
|
+
}
|
|
5909
|
+
}
|
|
5910
|
+
if (config.disabledRules.length > 0) {
|
|
5911
|
+
const disabled = new Set(config.disabledRules);
|
|
5912
|
+
findings = findings.filter((f) => !disabled.has(f.ruleId));
|
|
5913
|
+
}
|
|
5914
|
+
if (Object.keys(config.severityOverrides).length > 0) {
|
|
5915
|
+
findings = findings.map((f) => {
|
|
5916
|
+
const override = config.severityOverrides[f.ruleId];
|
|
5917
|
+
return override ? { ...f, severity: override } : f;
|
|
5918
|
+
});
|
|
5919
|
+
}
|
|
5920
|
+
findings.sort((a, b) => SEVERITY_ORDER[b.severity] - SEVERITY_ORDER[a.severity]);
|
|
5921
|
+
const summary = { critical: 0, error: 0, warning: 0, info: 0 };
|
|
5922
|
+
for (const f of findings) summary[f.severity]++;
|
|
5923
|
+
const failThreshold = SEVERITY_ORDER[config.failOn];
|
|
5924
|
+
const passed = !findings.some((f) => SEVERITY_ORDER[f.severity] >= failThreshold);
|
|
5925
|
+
return {
|
|
5926
|
+
scanners: applicableScanners.map((s) => s.name),
|
|
5927
|
+
findings,
|
|
5928
|
+
summary,
|
|
5929
|
+
passed,
|
|
5930
|
+
durationMs: Date.now() - start
|
|
5931
|
+
};
|
|
5932
|
+
}
|
|
5933
|
+
|
|
4281
5934
|
// src/utils/sort.ts
|
|
4282
5935
|
function getNestedValue(obj, path) {
|
|
4283
5936
|
const parts = path.split(".");
|
|
@@ -4319,16 +5972,16 @@ function sortResults(items, sortSpec) {
|
|
|
4319
5972
|
}
|
|
4320
5973
|
|
|
4321
5974
|
// src/commands/plugin-scaffold.ts
|
|
4322
|
-
import { mkdir as
|
|
4323
|
-
import { join as
|
|
5975
|
+
import { mkdir as mkdir6, writeFile as writeFile7 } from "fs/promises";
|
|
5976
|
+
import { join as join10 } from "path";
|
|
4324
5977
|
async function scaffoldPlugin(options) {
|
|
4325
5978
|
const { name, dir, description = `GPC plugin: ${name}` } = options;
|
|
4326
5979
|
const pluginName = name.startsWith("gpc-plugin-") ? name : `gpc-plugin-${name}`;
|
|
4327
5980
|
const shortName = pluginName.replace(/^gpc-plugin-/, "");
|
|
4328
|
-
const srcDir =
|
|
4329
|
-
const testDir =
|
|
4330
|
-
await
|
|
4331
|
-
await
|
|
5981
|
+
const srcDir = join10(dir, "src");
|
|
5982
|
+
const testDir = join10(dir, "tests");
|
|
5983
|
+
await mkdir6(srcDir, { recursive: true });
|
|
5984
|
+
await mkdir6(testDir, { recursive: true });
|
|
4332
5985
|
const files = [];
|
|
4333
5986
|
const pkg = {
|
|
4334
5987
|
name: pluginName,
|
|
@@ -4362,7 +6015,7 @@ async function scaffoldPlugin(options) {
|
|
|
4362
6015
|
vitest: "^3.0.0"
|
|
4363
6016
|
}
|
|
4364
6017
|
};
|
|
4365
|
-
await
|
|
6018
|
+
await writeFile7(join10(dir, "package.json"), JSON.stringify(pkg, null, 2) + "\n");
|
|
4366
6019
|
files.push("package.json");
|
|
4367
6020
|
const tsconfig = {
|
|
4368
6021
|
compilerOptions: {
|
|
@@ -4378,7 +6031,7 @@ async function scaffoldPlugin(options) {
|
|
|
4378
6031
|
},
|
|
4379
6032
|
include: ["src"]
|
|
4380
6033
|
};
|
|
4381
|
-
await
|
|
6034
|
+
await writeFile7(join10(dir, "tsconfig.json"), JSON.stringify(tsconfig, null, 2) + "\n");
|
|
4382
6035
|
files.push("tsconfig.json");
|
|
4383
6036
|
const srcContent = `import { definePlugin } from "@gpc-cli/plugin-sdk";
|
|
4384
6037
|
import type { CommandEvent, CommandResult } from "@gpc-cli/plugin-sdk";
|
|
@@ -4411,7 +6064,7 @@ export const plugin = definePlugin({
|
|
|
4411
6064
|
},
|
|
4412
6065
|
});
|
|
4413
6066
|
`;
|
|
4414
|
-
await
|
|
6067
|
+
await writeFile7(join10(srcDir, "index.ts"), srcContent);
|
|
4415
6068
|
files.push("src/index.ts");
|
|
4416
6069
|
const testContent = `import { describe, it, expect, vi } from "vitest";
|
|
4417
6070
|
import { plugin } from "../src/index";
|
|
@@ -4436,7 +6089,7 @@ describe("${pluginName}", () => {
|
|
|
4436
6089
|
});
|
|
4437
6090
|
});
|
|
4438
6091
|
`;
|
|
4439
|
-
await
|
|
6092
|
+
await writeFile7(join10(testDir, "plugin.test.ts"), testContent);
|
|
4440
6093
|
files.push("tests/plugin.test.ts");
|
|
4441
6094
|
return { dir, files };
|
|
4442
6095
|
}
|
|
@@ -4547,7 +6200,7 @@ async function sendWebhook(config, payload, target) {
|
|
|
4547
6200
|
}
|
|
4548
6201
|
|
|
4549
6202
|
// src/commands/internal-sharing.ts
|
|
4550
|
-
import { extname as
|
|
6203
|
+
import { extname as extname5 } from "path";
|
|
4551
6204
|
async function uploadInternalSharing(client, packageName, filePath, fileType) {
|
|
4552
6205
|
const resolvedType = fileType ?? detectFileType(filePath);
|
|
4553
6206
|
const validation = await validateUploadFile(filePath);
|
|
@@ -4574,7 +6227,7 @@ ${validation.errors.join("\n")}`,
|
|
|
4574
6227
|
};
|
|
4575
6228
|
}
|
|
4576
6229
|
function detectFileType(filePath) {
|
|
4577
|
-
const ext =
|
|
6230
|
+
const ext = extname5(filePath).toLowerCase();
|
|
4578
6231
|
if (ext === ".aab") return "bundle";
|
|
4579
6232
|
if (ext === ".apk") return "apk";
|
|
4580
6233
|
throw new GpcError(
|
|
@@ -4586,7 +6239,7 @@ function detectFileType(filePath) {
|
|
|
4586
6239
|
}
|
|
4587
6240
|
|
|
4588
6241
|
// src/commands/generated-apks.ts
|
|
4589
|
-
import { writeFile as
|
|
6242
|
+
import { writeFile as writeFile8 } from "fs/promises";
|
|
4590
6243
|
async function listGeneratedApks(client, packageName, versionCode) {
|
|
4591
6244
|
if (!Number.isInteger(versionCode) || versionCode <= 0) {
|
|
4592
6245
|
throw new GpcError(
|
|
@@ -4617,7 +6270,7 @@ async function downloadGeneratedApk(client, packageName, versionCode, apkId, out
|
|
|
4617
6270
|
}
|
|
4618
6271
|
const buffer = await client.generatedApks.download(packageName, versionCode, apkId);
|
|
4619
6272
|
const bytes = new Uint8Array(buffer);
|
|
4620
|
-
await
|
|
6273
|
+
await writeFile8(outputPath, bytes);
|
|
4621
6274
|
return { path: outputPath, sizeBytes: bytes.byteLength };
|
|
4622
6275
|
}
|
|
4623
6276
|
|
|
@@ -4684,11 +6337,11 @@ async function deactivatePurchaseOption(client, packageName, purchaseOptionId) {
|
|
|
4684
6337
|
}
|
|
4685
6338
|
|
|
4686
6339
|
// src/commands/bundle-analysis.ts
|
|
4687
|
-
import { readFile as
|
|
6340
|
+
import { readFile as readFile14, stat as stat10 } from "fs/promises";
|
|
4688
6341
|
var EOCD_SIGNATURE = 101010256;
|
|
4689
6342
|
var CD_SIGNATURE = 33639248;
|
|
4690
6343
|
var MODULE_SUBDIRS = /* @__PURE__ */ new Set(["dex", "manifest", "res", "assets", "lib", "resources.pb", "root"]);
|
|
4691
|
-
function
|
|
6344
|
+
function detectCategory2(path) {
|
|
4692
6345
|
const lower = path.toLowerCase();
|
|
4693
6346
|
if (lower.endsWith(".dex") || /\/dex\/[^/]+\.dex$/.test(lower)) return "dex";
|
|
4694
6347
|
if (lower === "resources.arsc" || lower.endsWith("/resources.arsc") || lower.endsWith("/resources.pb") || /^(([^/]+\/)?res\/)/.test(lower))
|
|
@@ -4760,18 +6413,18 @@ function detectFileType2(filePath) {
|
|
|
4760
6413
|
return "apk";
|
|
4761
6414
|
}
|
|
4762
6415
|
async function analyzeBundle(filePath) {
|
|
4763
|
-
const fileInfo = await
|
|
6416
|
+
const fileInfo = await stat10(filePath).catch(() => null);
|
|
4764
6417
|
if (!fileInfo || !fileInfo.isFile()) {
|
|
4765
6418
|
throw new Error(`File not found: ${filePath}`);
|
|
4766
6419
|
}
|
|
4767
|
-
const buf = await
|
|
6420
|
+
const buf = await readFile14(filePath);
|
|
4768
6421
|
const cdEntries = parseCentralDirectory(buf);
|
|
4769
6422
|
const fileType = detectFileType2(filePath);
|
|
4770
6423
|
const isAab = fileType === "aab";
|
|
4771
6424
|
const entries = cdEntries.map((e) => ({
|
|
4772
6425
|
path: e.filename,
|
|
4773
6426
|
module: detectModule(e.filename, isAab),
|
|
4774
|
-
category:
|
|
6427
|
+
category: detectCategory2(e.filename),
|
|
4775
6428
|
compressedSize: e.compressedSize,
|
|
4776
6429
|
uncompressedSize: e.uncompressedSize
|
|
4777
6430
|
}));
|
|
@@ -4850,7 +6503,7 @@ function topFiles(analysis, n = 20) {
|
|
|
4850
6503
|
async function checkBundleSize(analysis, configPath = ".bundlesize.json") {
|
|
4851
6504
|
let config;
|
|
4852
6505
|
try {
|
|
4853
|
-
const raw = await
|
|
6506
|
+
const raw = await readFile14(configPath, "utf-8");
|
|
4854
6507
|
config = JSON.parse(raw);
|
|
4855
6508
|
} catch {
|
|
4856
6509
|
return { passed: true, violations: [] };
|
|
@@ -4879,17 +6532,17 @@ async function checkBundleSize(analysis, configPath = ".bundlesize.json") {
|
|
|
4879
6532
|
}
|
|
4880
6533
|
|
|
4881
6534
|
// src/commands/status.ts
|
|
4882
|
-
import { mkdir as
|
|
6535
|
+
import { mkdir as mkdir7, readFile as readFile15, writeFile as writeFile9 } from "fs/promises";
|
|
4883
6536
|
import { execFile as execFile2 } from "child_process";
|
|
4884
|
-
import { join as
|
|
6537
|
+
import { join as join11 } from "path";
|
|
4885
6538
|
import { getCacheDir as getCacheDir2 } from "@gpc-cli/config";
|
|
4886
6539
|
var DEFAULT_TTL_SECONDS = 3600;
|
|
4887
6540
|
function cacheFilePath(packageName) {
|
|
4888
|
-
return
|
|
6541
|
+
return join11(getCacheDir2(), `status-${packageName}.json`);
|
|
4889
6542
|
}
|
|
4890
6543
|
async function loadStatusCache(packageName, ttlSeconds = DEFAULT_TTL_SECONDS) {
|
|
4891
6544
|
try {
|
|
4892
|
-
const raw = await
|
|
6545
|
+
const raw = await readFile15(cacheFilePath(packageName), "utf-8");
|
|
4893
6546
|
const entry = JSON.parse(raw);
|
|
4894
6547
|
const age = (Date.now() - new Date(entry.fetchedAt).getTime()) / 1e3;
|
|
4895
6548
|
if (age > (entry.ttl ?? ttlSeconds)) return null;
|
|
@@ -4906,9 +6559,9 @@ async function loadStatusCache(packageName, ttlSeconds = DEFAULT_TTL_SECONDS) {
|
|
|
4906
6559
|
async function saveStatusCache(packageName, data, ttlSeconds = DEFAULT_TTL_SECONDS) {
|
|
4907
6560
|
try {
|
|
4908
6561
|
const dir = getCacheDir2();
|
|
4909
|
-
await
|
|
6562
|
+
await mkdir7(dir, { recursive: true });
|
|
4910
6563
|
const entry = { fetchedAt: data.fetchedAt, ttl: ttlSeconds, data };
|
|
4911
|
-
await
|
|
6564
|
+
await writeFile9(cacheFilePath(packageName), JSON.stringify(entry, null, 2), {
|
|
4912
6565
|
encoding: "utf-8",
|
|
4913
6566
|
mode: 384
|
|
4914
6567
|
});
|
|
@@ -5012,6 +6665,7 @@ function computeReviewSentiment(reviews, windowDays) {
|
|
|
5012
6665
|
}
|
|
5013
6666
|
async function getAppStatus(client, reporting, packageName, options = {}) {
|
|
5014
6667
|
const days = options.days ?? 7;
|
|
6668
|
+
const reviewDays = options.reviewDays ?? 30;
|
|
5015
6669
|
const sections = new Set(options.sections ?? ["releases", "vitals", "reviews"]);
|
|
5016
6670
|
const thresholds = {
|
|
5017
6671
|
crashRate: options.vitalThresholds?.crashRate ?? DEFAULT_THRESHOLDS.crashRate,
|
|
@@ -5046,7 +6700,7 @@ async function getAppStatus(client, reporting, packageName, options = {}) {
|
|
|
5046
6700
|
const slowStart = slowStartResult.status === "fulfilled" ? slowStartResult.value : SKIPPED_VITAL;
|
|
5047
6701
|
const slowRender = slowRenderResult.status === "fulfilled" ? slowRenderResult.value : SKIPPED_VITAL;
|
|
5048
6702
|
const rawReviews = reviewsResult.status === "fulfilled" ? reviewsResult.value : [];
|
|
5049
|
-
const reviews = computeReviewSentiment(rawReviews,
|
|
6703
|
+
const reviews = computeReviewSentiment(rawReviews, reviewDays);
|
|
5050
6704
|
return {
|
|
5051
6705
|
packageName,
|
|
5052
6706
|
fetchedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
@@ -5269,6 +6923,7 @@ async function runWatchLoop(opts) {
|
|
|
5269
6923
|
process.on("SIGTERM", cleanup);
|
|
5270
6924
|
while (running) {
|
|
5271
6925
|
process.stdout.write("\x1B[2J\x1B[H");
|
|
6926
|
+
const fetchedAt = Date.now();
|
|
5272
6927
|
try {
|
|
5273
6928
|
const status = await opts.fetch();
|
|
5274
6929
|
await opts.save(status);
|
|
@@ -5276,28 +6931,31 @@ async function runWatchLoop(opts) {
|
|
|
5276
6931
|
} catch (err) {
|
|
5277
6932
|
console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
|
|
5278
6933
|
}
|
|
5279
|
-
console.log(`
|
|
5280
|
-
[gpc status] Refreshing in ${opts.intervalSeconds}s\u2026 (Ctrl+C to stop)`);
|
|
5281
6934
|
for (let i = 0; i < opts.intervalSeconds && running; i++) {
|
|
6935
|
+
const elapsed = Math.round((Date.now() - fetchedAt) / 1e3);
|
|
6936
|
+
const remaining = opts.intervalSeconds - i;
|
|
6937
|
+
process.stdout.write(
|
|
6938
|
+
`\r[gpc status] Fetched ${elapsed}s ago \xB7 refreshing in ${remaining}s (Ctrl+C to stop)\x1B[K`
|
|
6939
|
+
);
|
|
5282
6940
|
await new Promise((r) => setTimeout(r, 1e3));
|
|
5283
6941
|
}
|
|
5284
6942
|
}
|
|
5285
6943
|
}
|
|
5286
6944
|
function breachStateFilePath(packageName) {
|
|
5287
|
-
return
|
|
6945
|
+
return join11(getCacheDir2(), `breach-state-${packageName}.json`);
|
|
5288
6946
|
}
|
|
5289
6947
|
async function trackBreachState(packageName, isBreaching) {
|
|
5290
6948
|
const filePath = breachStateFilePath(packageName);
|
|
5291
6949
|
let prevBreaching = false;
|
|
5292
6950
|
try {
|
|
5293
|
-
const raw = await
|
|
6951
|
+
const raw = await readFile15(filePath, "utf-8");
|
|
5294
6952
|
prevBreaching = JSON.parse(raw).breaching;
|
|
5295
6953
|
} catch {
|
|
5296
6954
|
}
|
|
5297
6955
|
if (prevBreaching !== isBreaching) {
|
|
5298
6956
|
try {
|
|
5299
|
-
await
|
|
5300
|
-
await
|
|
6957
|
+
await mkdir7(getCacheDir2(), { recursive: true });
|
|
6958
|
+
await writeFile9(
|
|
5301
6959
|
filePath,
|
|
5302
6960
|
JSON.stringify({ breaching: isBreaching, since: (/* @__PURE__ */ new Date()).toISOString() }, null, 2),
|
|
5303
6961
|
{ encoding: "utf-8", mode: 384 }
|
|
@@ -5338,6 +6996,7 @@ export {
|
|
|
5338
6996
|
ApiError,
|
|
5339
6997
|
ConfigError,
|
|
5340
6998
|
DEFAULT_LIMITS,
|
|
6999
|
+
DEFAULT_PREFLIGHT_CONFIG,
|
|
5341
7000
|
GOOGLE_PLAY_LANGUAGES,
|
|
5342
7001
|
GpcError,
|
|
5343
7002
|
NetworkError,
|
|
@@ -5345,6 +7004,7 @@ export {
|
|
|
5345
7004
|
PluginManager,
|
|
5346
7005
|
SENSITIVE_ARG_KEYS,
|
|
5347
7006
|
SENSITIVE_KEYS,
|
|
7007
|
+
SEVERITY_ORDER,
|
|
5348
7008
|
abortTrain,
|
|
5349
7009
|
acknowledgeProductPurchase,
|
|
5350
7010
|
activateBasePlan,
|
|
@@ -5410,6 +7070,7 @@ export {
|
|
|
5410
7070
|
exportDataSafety,
|
|
5411
7071
|
exportImages,
|
|
5412
7072
|
exportReviews,
|
|
7073
|
+
fetchReleaseNotes,
|
|
5413
7074
|
formatCustomPayload,
|
|
5414
7075
|
formatDiscordPayload,
|
|
5415
7076
|
formatJunit,
|
|
@@ -5421,6 +7082,7 @@ export {
|
|
|
5421
7082
|
formatWordDiff,
|
|
5422
7083
|
generateMigrationPlan,
|
|
5423
7084
|
generateNotesFromGit,
|
|
7085
|
+
getAllScannerNames,
|
|
5424
7086
|
getAppInfo,
|
|
5425
7087
|
getAppStatus,
|
|
5426
7088
|
getCountryAvailability,
|
|
@@ -5454,6 +7116,7 @@ export {
|
|
|
5454
7116
|
importDataSafety,
|
|
5455
7117
|
importTestersFromCsv,
|
|
5456
7118
|
initAudit,
|
|
7119
|
+
initProject,
|
|
5457
7120
|
inviteUser,
|
|
5458
7121
|
isFinancialReportType,
|
|
5459
7122
|
isStatsReportType,
|
|
@@ -5485,6 +7148,7 @@ export {
|
|
|
5485
7148
|
listTracks,
|
|
5486
7149
|
listUsers,
|
|
5487
7150
|
listVoidedPurchases,
|
|
7151
|
+
loadPreflightConfig,
|
|
5488
7152
|
loadStatusCache,
|
|
5489
7153
|
maybePaginate,
|
|
5490
7154
|
migratePrices,
|
|
@@ -5508,6 +7172,7 @@ export {
|
|
|
5508
7172
|
removeUser,
|
|
5509
7173
|
replyToReview,
|
|
5510
7174
|
revokeSubscriptionPurchase,
|
|
7175
|
+
runPreflight,
|
|
5511
7176
|
runWatchLoop,
|
|
5512
7177
|
safePath,
|
|
5513
7178
|
safePathWithin,
|