@gpc-cli/core 0.10.6 → 0.10.8

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.js CHANGED
@@ -2686,6 +2686,100 @@ var METRIC_SET_METRICS = {
2686
2686
  ],
2687
2687
  errorCountMetricSet: ["errorReportCount", "distinctUsers"]
2688
2688
  };
2689
+ var REQUIRED_DIMENSIONS = {
2690
+ slowStartRateMetricSet: ["startType"],
2691
+ errorCountMetricSet: ["reportType"]
2692
+ };
2693
+ function withRequiredDimensions(metricSet, query) {
2694
+ const required = REQUIRED_DIMENSIONS[metricSet];
2695
+ if (!required) return query;
2696
+ const dimensions = [...required];
2697
+ for (const dim2 of query.dimensions ?? []) {
2698
+ if (!dimensions.includes(dim2)) dimensions.push(dim2);
2699
+ }
2700
+ return { ...query, dimensions };
2701
+ }
2702
+ function readMetricValue(row, metric) {
2703
+ const metrics = row?.metrics;
2704
+ if (!metrics || typeof metrics !== "object") return void 0;
2705
+ let entry;
2706
+ if (Array.isArray(metrics)) {
2707
+ entry = metric ? metrics.find((m) => m?.metric === metric) : metrics[0];
2708
+ } else {
2709
+ const map = metrics;
2710
+ const key = metric ?? Object.keys(map)[0];
2711
+ entry = key ? map[key] : void 0;
2712
+ }
2713
+ const raw = entry?.decimalValue?.value;
2714
+ if (raw === void 0) return void 0;
2715
+ const value = Number(raw);
2716
+ return Number.isFinite(value) ? value : void 0;
2717
+ }
2718
+ function rowDateKey(row) {
2719
+ const t = row.startTime;
2720
+ if (!t) return "";
2721
+ const pad = (n) => String(n ?? 0).padStart(2, "0");
2722
+ return `${t.year ?? 0}-${pad(t.month)}-${pad(t.day)}T${pad(t.hours)}`;
2723
+ }
2724
+ function rowSliceKey(row) {
2725
+ const dims = row.dimensions;
2726
+ if (!dims || typeof dims !== "object") return "";
2727
+ const pairs = Array.isArray(dims) ? dims.map((d) => [d?.dimension ?? "", d?.stringValue ?? d?.int64Value ?? ""]) : Object.entries(dims).map(([k, d]) => [
2728
+ k,
2729
+ d?.stringValue ?? d?.int64Value ?? ""
2730
+ ]);
2731
+ return JSON.stringify(pairs.sort());
2732
+ }
2733
+ function latestWorstValue(rows, metric) {
2734
+ if (!rows || rows.length === 0) return void 0;
2735
+ const latest = rows.map(rowDateKey).reduce((a, b) => b > a ? b : a);
2736
+ let worst;
2737
+ for (const row of rows) {
2738
+ if (rowDateKey(row) !== latest) continue;
2739
+ const value = readMetricValue(row, metric);
2740
+ if (value !== void 0 && (worst === void 0 || value > worst)) worst = value;
2741
+ }
2742
+ return worst;
2743
+ }
2744
+ function worstSliceAverage(rows, metric) {
2745
+ const sums = /* @__PURE__ */ new Map();
2746
+ for (const row of rows ?? []) {
2747
+ const value = readMetricValue(row, metric);
2748
+ if (value === void 0) continue;
2749
+ const key = rowSliceKey(row);
2750
+ const entry = sums.get(key) ?? { total: 0, count: 0 };
2751
+ entry.total += value;
2752
+ entry.count++;
2753
+ sums.set(key, entry);
2754
+ }
2755
+ let worst;
2756
+ for (const { total, count } of sums.values()) {
2757
+ const avg = total / count;
2758
+ if (worst === void 0 || avg > worst) worst = avg;
2759
+ }
2760
+ return worst;
2761
+ }
2762
+ function assertRateThreshold(label, threshold) {
2763
+ if (!Number.isFinite(threshold) || threshold < 0) {
2764
+ throw new GpcError(
2765
+ `Invalid ${label} threshold: expected a fraction such as 0.02.`,
2766
+ "THRESHOLD_INVALID",
2767
+ 2,
2768
+ "Write rates as fractions: 0.02 means 2%."
2769
+ );
2770
+ }
2771
+ if (threshold >= 1) {
2772
+ throw new GpcError(
2773
+ `${label} threshold ${threshold} can never be reached: rates are between 0 and 1.`,
2774
+ "THRESHOLD_INVALID",
2775
+ 2,
2776
+ `Write rates as fractions: ${threshold / 100} means ${threshold}%. Check vitals.thresholds in your config too.`
2777
+ );
2778
+ }
2779
+ }
2780
+ function isGamesOnlyMetricError(err) {
2781
+ return err?.code === "API_METRIC_GAMES_ONLY";
2782
+ }
2689
2783
  async function getFreshnessEndDate(reporting, packageName, metricSet, aggregation = "DAILY") {
2690
2784
  try {
2691
2785
  const info = await reporting.getMetricSetFreshness(packageName, metricSet);
@@ -2699,12 +2793,16 @@ async function getFreshnessEndDate(reporting, packageName, metricSet, aggregatio
2699
2793
  }
2700
2794
  return void 0;
2701
2795
  }
2796
+ async function latestQueryableEndDate(reporting, packageName, metricSet) {
2797
+ const fallback = new Date(Date.now() - 2 * 24 * 60 * 60 * 1e3);
2798
+ const freshnessEnd = await getFreshnessEndDate(reporting, packageName, metricSet);
2799
+ return freshnessEnd ?? fallback;
2800
+ }
2702
2801
  function buildQuery(metricSet, options, freshnessEnd) {
2703
2802
  const metrics = METRIC_SET_METRICS[metricSet] ?? ["errorReportCount", "distinctUsers"];
2704
2803
  const days = options?.days ?? 30;
2705
2804
  const DAY_MS = 24 * 60 * 60 * 1e3;
2706
- const yesterday = new Date(Date.now() - DAY_MS);
2707
- const end = freshnessEnd && freshnessEnd < yesterday ? freshnessEnd : yesterday;
2805
+ const end = freshnessEnd ?? new Date(Date.now() - 2 * DAY_MS);
2708
2806
  const start = new Date(end.getTime() - days * DAY_MS);
2709
2807
  const query = {
2710
2808
  metrics,
@@ -2725,7 +2823,7 @@ function buildQuery(metricSet, options, freshnessEnd) {
2725
2823
  if (options?.dimension) {
2726
2824
  query.dimensions = [options.dimension];
2727
2825
  }
2728
- return query;
2826
+ return withRequiredDimensions(metricSet, query);
2729
2827
  }
2730
2828
  async function queryMetric(reporting, packageName, metricSet, options) {
2731
2829
  const freshnessEnd = await getFreshnessEndDate(
@@ -2761,16 +2859,29 @@ async function getVitalsOverview(reporting, packageName) {
2761
2859
  })
2762
2860
  );
2763
2861
  const overview = {};
2862
+ const unavailable = [];
2863
+ let firstFailure;
2864
+ let succeeded = 0;
2764
2865
  for (let i = 0; i < metricSets.length; i++) {
2765
2866
  const entry = metricSets[i];
2766
- if (!entry) continue;
2767
- const key = entry[1];
2768
2867
  const result = results[i];
2769
- if (!result) continue;
2868
+ if (!entry || !result) continue;
2869
+ const key = entry[1];
2770
2870
  if (result.status === "fulfilled") {
2771
2871
  overview[key] = result.value.rows || [];
2872
+ succeeded++;
2873
+ continue;
2772
2874
  }
2875
+ const err = result.reason;
2876
+ if (!isGamesOnlyMetricError(err)) firstFailure ??= result.reason;
2877
+ unavailable.push({
2878
+ metric: key,
2879
+ code: typeof err?.code === "string" ? err.code : "UNKNOWN_ERROR",
2880
+ reason: isGamesOnlyMetricError(err) ? "Only available for apps categorised as games." : typeof err?.message === "string" ? err.message : String(result.reason)
2881
+ });
2773
2882
  }
2883
+ if (succeeded === 0 && firstFailure !== void 0) throw firstFailure;
2884
+ if (unavailable.length > 0) overview.unavailable = unavailable;
2774
2885
  return overview;
2775
2886
  }
2776
2887
  async function getVitalsCrashes(reporting, packageName, options) {
@@ -2780,8 +2891,7 @@ async function getVitalsAnr(reporting, packageName, options) {
2780
2891
  return queryMetric(reporting, packageName, "anrRateMetricSet", options);
2781
2892
  }
2782
2893
  async function getVitalsStartup(reporting, packageName, options) {
2783
- const opts = options?.dimension ? options : { ...options, dimension: "startType" };
2784
- return queryMetric(reporting, packageName, "slowStartRateMetricSet", opts);
2894
+ return queryMetric(reporting, packageName, "slowStartRateMetricSet", options);
2785
2895
  }
2786
2896
  async function getVitalsRendering(reporting, packageName, options) {
2787
2897
  return queryMetric(reporting, packageName, "slowRenderingRateMetricSet", options);
@@ -2811,8 +2921,7 @@ async function getVitalsMemoryBitmap(reporting, packageName, options) {
2811
2921
  });
2812
2922
  }
2813
2923
  async function getVitalsErrorCount(reporting, packageName, options) {
2814
- const opts = options?.dimension ? options : { ...options, dimension: "reportType" };
2815
- return queryMetric(reporting, packageName, "errorCountMetricSet", opts);
2924
+ return queryMetric(reporting, packageName, "errorCountMetricSet", options);
2816
2925
  }
2817
2926
  async function getVitalsAnomalies(reporting, packageName) {
2818
2927
  return reporting.getAnomalies(packageName);
@@ -2836,7 +2945,7 @@ async function compareVitalsTrend(reporting, packageName, metricSet, days = 7) {
2836
2945
  month: d.getUTCMonth() + 1,
2837
2946
  day: d.getUTCDate()
2838
2947
  });
2839
- const makeQuery = (start, end) => ({
2948
+ const makeQuery = (start, end) => withRequiredDimensions(metricSet, {
2840
2949
  metrics,
2841
2950
  timelineSpec: {
2842
2951
  aggregationPeriod: "DAILY",
@@ -2940,7 +3049,8 @@ function watchVitalsWithAutoHalt(reporting, packageName, options) {
2940
3049
  threshold,
2941
3050
  metricSet = "crashRateMetricSet",
2942
3051
  onHalt,
2943
- onPoll
3052
+ onPoll,
3053
+ onError
2944
3054
  } = options;
2945
3055
  let stopped = false;
2946
3056
  let haltTriggered = false;
@@ -2948,16 +3058,15 @@ function watchVitalsWithAutoHalt(reporting, packageName, options) {
2948
3058
  if (stopped) return;
2949
3059
  try {
2950
3060
  const result = await queryMetric(reporting, packageName, metricSet, { days: 1 });
2951
- const latestRow = result.rows?.[result.rows.length - 1];
2952
- const firstMetric = latestRow?.metrics ? Object.keys(latestRow.metrics)[0] : void 0;
2953
- const value = firstMetric ? Number(latestRow?.metrics[firstMetric]?.decimalValue?.value) : void 0;
3061
+ const value = latestWorstValue(result.rows, METRIC_SET_METRICS[metricSet]?.[0]);
2954
3062
  const breached = value !== void 0 && value > threshold;
2955
3063
  onPoll?.(value, breached);
2956
3064
  if (breached && !haltTriggered && onHalt) {
2957
3065
  haltTriggered = true;
2958
3066
  await onHalt(value);
2959
3067
  }
2960
- } catch {
3068
+ } catch (err) {
3069
+ onError?.(err);
2961
3070
  }
2962
3071
  if (!stopped) {
2963
3072
  timerId = setTimeout(poll, intervalMs);
@@ -3023,33 +3132,28 @@ var WARN_MARGIN = 0.2;
3023
3132
  function toApiDate(d) {
3024
3133
  return { year: d.getUTCFullYear(), month: d.getUTCMonth() + 1, day: d.getUTCDate() };
3025
3134
  }
3026
- async function queryVitalForStatus(reporting, packageName, metricSet, days, offsetDays = 0) {
3135
+ async function queryVitalForStatus(reporting, packageName, metricSet, days, latestEnd, offsetDays = 0) {
3027
3136
  const DAY_MS = 24 * 60 * 60 * 1e3;
3028
- const baseMs = Date.now() - 2 * DAY_MS - offsetDays * DAY_MS;
3137
+ const baseMs = latestEnd.getTime() - offsetDays * DAY_MS;
3029
3138
  const end = new Date(baseMs);
3030
3139
  const start = new Date(baseMs - days * DAY_MS);
3031
3140
  const metrics = METRIC_SET_METRICS2[metricSet] ?? ["distinctUsers"];
3032
- const query = {
3141
+ const query = withRequiredDimensions(metricSet, {
3033
3142
  metrics,
3034
3143
  timelineSpec: {
3035
3144
  aggregationPeriod: "DAILY",
3036
3145
  startTime: toApiDate(start),
3037
3146
  endTime: toApiDate(end)
3038
3147
  }
3039
- };
3148
+ });
3040
3149
  const result = await reporting.queryMetricSet(packageName, metricSet, query);
3041
- if (!result.rows || result.rows.length === 0) return void 0;
3042
- const values = result.rows.map((row) => {
3043
- const firstKey = Object.keys(row.metrics)[0];
3044
- return firstKey ? Number(row.metrics[firstKey]?.decimalValue?.value) : NaN;
3045
- }).filter((v) => !isNaN(v));
3046
- if (values.length === 0) return void 0;
3047
- return values.reduce((a, b) => a + b, 0) / values.length;
3150
+ return worstSliceAverage(result.rows, metrics[0]);
3048
3151
  }
3049
3152
  async function queryVitalWithTrend(reporting, packageName, metricSet, days) {
3153
+ const latestEnd = await latestQueryableEndDate(reporting, packageName, metricSet);
3050
3154
  const [current, previous] = await Promise.all([
3051
- queryVitalForStatus(reporting, packageName, metricSet, days, 0),
3052
- queryVitalForStatus(reporting, packageName, metricSet, days, days)
3155
+ queryVitalForStatus(reporting, packageName, metricSet, days, latestEnd, 0),
3156
+ queryVitalForStatus(reporting, packageName, metricSet, days, latestEnd, days)
3053
3157
  ]);
3054
3158
  let trend = null;
3055
3159
  if (current !== void 0 && previous !== void 0) {
@@ -3147,7 +3251,7 @@ async function getAppStatus(client, reporting, packageName, options = {}) {
3147
3251
  if (slowStartResult.status === "rejected")
3148
3252
  sectionErrors.push(`vitals/slowStarts: ${String(slowStartResult.reason)}`);
3149
3253
  const slowRender = slowRenderResult.status === "fulfilled" ? slowRenderResult.value : SKIPPED_VITAL;
3150
- if (slowRenderResult.status === "rejected")
3254
+ if (slowRenderResult.status === "rejected" && !isGamesOnlyMetricError(slowRenderResult.reason))
3151
3255
  sectionErrors.push(`vitals/slowRender: ${String(slowRenderResult.reason)}`);
3152
3256
  const rawReviews = reviewsResult.status === "fulfilled" ? reviewsResult.value.reviews : [];
3153
3257
  if (reviewsResult.status === "rejected")
@@ -3528,31 +3632,34 @@ async function queryWatchMetric(reporting, packageName, metric) {
3528
3632
  const metricSet = METRIC_TO_SET[metric];
3529
3633
  const metricKey = METRIC_KEY[metric];
3530
3634
  const DAY_MS = 24 * 60 * 60 * 1e3;
3531
- const end = new Date(Date.now() - 2 * DAY_MS);
3532
- const start = new Date(end.getTime() - 7 * DAY_MS);
3533
3635
  const toDate = (d) => ({
3534
3636
  year: d.getUTCFullYear(),
3535
3637
  month: d.getUTCMonth() + 1,
3536
3638
  day: d.getUTCDate()
3537
3639
  });
3538
3640
  try {
3539
- const result = await reporting.queryMetricSet(packageName, metricSet, {
3540
- metrics: [metricKey, "distinctUsers"],
3541
- timelineSpec: {
3542
- aggregationPeriod: "DAILY",
3543
- startTime: toDate(start),
3544
- endTime: toDate(end)
3545
- }
3546
- });
3547
- if (!result.rows || result.rows.length === 0) return void 0;
3548
- const values = result.rows.map((row) => {
3549
- const val = row.metrics[metricKey]?.decimalValue?.value;
3550
- return val !== void 0 ? Number(val) : NaN;
3551
- }).filter((v) => !isNaN(v));
3552
- if (values.length === 0) return void 0;
3553
- return values.reduce((a, b) => a + b, 0) / values.length;
3554
- } catch {
3555
- return void 0;
3641
+ const end = await latestQueryableEndDate(reporting, packageName, metricSet);
3642
+ const start = new Date(end.getTime() - 7 * DAY_MS);
3643
+ const result = await reporting.queryMetricSet(
3644
+ packageName,
3645
+ metricSet,
3646
+ withRequiredDimensions(metricSet, {
3647
+ metrics: [metricKey, "distinctUsers"],
3648
+ timelineSpec: {
3649
+ aggregationPeriod: "DAILY",
3650
+ startTime: toDate(start),
3651
+ endTime: toDate(end)
3652
+ }
3653
+ })
3654
+ );
3655
+ return { value: latestWorstValue(result.rows, metricKey) };
3656
+ } catch (err) {
3657
+ const code = err?.code;
3658
+ return {
3659
+ value: void 0,
3660
+ error: typeof code === "string" ? code : err instanceof Error ? err.message : String(err),
3661
+ gamesOnly: isGamesOnlyMetricError(err)
3662
+ };
3556
3663
  }
3557
3664
  }
3558
3665
  async function fetchRollout(client, packageName, track) {
@@ -3631,12 +3738,17 @@ async function runWatch(client, reporting, config, callbacks) {
3631
3738
  "Watch interval must be at least 60 seconds",
3632
3739
  "WATCH_USAGE_ERROR",
3633
3740
  2,
3634
- "Use --interval 60 or higher. Google vitals data is delayed 24-48h, so frequent polling wastes API quota."
3741
+ "Use --interval 60 or higher. Google vitals data is delayed 2-4 days, so frequent polling wastes API quota."
3635
3742
  );
3636
3743
  }
3744
+ for (const [metric, threshold] of Object.entries(config.thresholds)) {
3745
+ if (metric !== "errorCount" && threshold !== void 0) assertRateThreshold(metric, threshold);
3746
+ }
3637
3747
  let running = true;
3638
3748
  const events = [];
3639
3749
  let breachCount = 0;
3750
+ let unreadableRounds = 0;
3751
+ let lastRoundUnreadable = false;
3640
3752
  let halted = false;
3641
3753
  const startTime = Date.now();
3642
3754
  const cleanup = () => {
@@ -3652,19 +3764,24 @@ async function runWatch(client, reporting, config, callbacks) {
3652
3764
  const vitals = {};
3653
3765
  const metricResults = await Promise.all(
3654
3766
  config.metrics.map(async (metric) => {
3655
- const value = await queryWatchMetric(reporting, config.packageName, metric);
3656
- return { metric, value };
3767
+ const reading = await queryWatchMetric(reporting, config.packageName, metric);
3768
+ return { metric, ...reading };
3657
3769
  })
3658
3770
  );
3659
- for (const { metric, value } of metricResults) {
3771
+ let unreadable = false;
3772
+ for (const { metric, value, error, gamesOnly } of metricResults) {
3660
3773
  const threshold = config.thresholds[metric];
3774
+ if (error && threshold !== void 0 && !gamesOnly) unreadable = true;
3661
3775
  const result = threshold !== void 0 ? checkThreshold(value, threshold) : { breached: false };
3662
3776
  vitals[metric] = {
3663
3777
  value,
3664
3778
  threshold,
3665
- breached: result.breached
3779
+ breached: result.breached,
3780
+ ...error ? { error } : {}
3666
3781
  };
3667
3782
  }
3783
+ if (unreadable) unreadableRounds++;
3784
+ lastRoundUnreadable = unreadable;
3668
3785
  const breaches = Object.entries(vitals).filter(([, v]) => v.breached).map(([k]) => k);
3669
3786
  const event = {
3670
3787
  round,
@@ -3716,6 +3833,8 @@ async function runWatch(client, reporting, config, callbacks) {
3716
3833
  durationMs: Date.now() - startTime,
3717
3834
  finalRollout: events[events.length - 1]?.rollout?.userFraction ?? null,
3718
3835
  breachCount,
3836
+ unreadableRounds,
3837
+ lastRoundUnreadable,
3719
3838
  halted,
3720
3839
  events
3721
3840
  };
@@ -5411,10 +5530,9 @@ async function advanceTrain(apiClient, reportingClient, packageName) {
5411
5530
  if (state.gates) {
5412
5531
  if (state.gates.crashes?.max !== void 0) {
5413
5532
  const result = await getVitalsCrashes(reportingClient, packageName, { days: 1 });
5414
- const latestRow = result.rows?.[result.rows.length - 1];
5415
- const firstMetric = latestRow?.metrics ? Object.keys(latestRow.metrics)[0] : void 0;
5416
- const value = firstMetric ? Number(latestRow?.metrics[firstMetric]?.decimalValue?.value) : void 0;
5417
- if (value !== void 0 && value > state.gates.crashes.max / 100) {
5533
+ const value = latestWorstValue(result.rows, "crashRate");
5534
+ if (value === void 0) throw trainGateNoData("Crash", "crashes");
5535
+ if (value > state.gates.crashes.max / 100) {
5418
5536
  state.status = "paused";
5419
5537
  state.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
5420
5538
  await writeTrainState(packageName, state);
@@ -5428,10 +5546,9 @@ async function advanceTrain(apiClient, reportingClient, packageName) {
5428
5546
  }
5429
5547
  if (state.gates.anr?.max !== void 0) {
5430
5548
  const result = await getVitalsAnr(reportingClient, packageName, { days: 1 });
5431
- const latestRow = result.rows?.[result.rows.length - 1];
5432
- const firstMetric = latestRow?.metrics ? Object.keys(latestRow.metrics)[0] : void 0;
5433
- const value = firstMetric ? Number(latestRow?.metrics[firstMetric]?.decimalValue?.value) : void 0;
5434
- if (value !== void 0 && value > state.gates.anr.max / 100) {
5549
+ const value = latestWorstValue(result.rows, "anrRate");
5550
+ if (value === void 0) throw trainGateNoData("ANR", "anr");
5551
+ if (value > state.gates.anr.max / 100) {
5435
5552
  state.status = "paused";
5436
5553
  state.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
5437
5554
  await writeTrainState(packageName, state);
@@ -5463,6 +5580,14 @@ async function executeStage(apiClient, packageName, state, stageIndex) {
5463
5580
  state.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
5464
5581
  await writeTrainState(packageName, state);
5465
5582
  }
5583
+ function trainGateNoData(label, command) {
5584
+ return new GpcError(
5585
+ `${label} gate has no data to check, so the train was not advanced.`,
5586
+ "TRAIN_GATE_NO_DATA",
5587
+ 6,
5588
+ `Vitals data often lags 2-4 days. Run the train again later, or check with: gpc vitals ${command} --days 3`
5589
+ );
5590
+ }
5466
5591
 
5467
5592
  // src/commands/games.ts
5468
5593
  import { mkdir as mkdir5, readdir as readdir4, readFile as readFile9, writeFile as writeFile5 } from "fs/promises";
@@ -6683,7 +6808,7 @@ var manifestScanner = {
6683
6808
  ruleId: "geofencing-foreground-service",
6684
6809
  severity: "warning",
6685
6810
  title: `Possible geofencing via foreground service "${service.name}"`,
6686
- message: `Service "${service.name}" uses foregroundServiceType "location" and the app declares ACCESS_BACKGROUND_LOCATION. Google Play no longer approves geofencing as a foreground service use case (sensitive-permissions policy announced April 15, 2026); enforcement for apps targeting Android 17 (API 37+) begins October 28, 2026.`,
6811
+ message: `Service "${service.name}" uses foregroundServiceType "location" and the app declares ACCESS_BACKGROUND_LOCATION. Google Play no longer approves geofencing as a foreground service use case (sensitive-permissions policy announced April 15, 2026).`,
6687
6812
  suggestion: 'If this service performs geofencing, migrate to the Geofencing API (LocationServices.getGeofencingClient) or WorkManager/AlarmManager. If this is legitimate background location tracking (navigation, fitness), suppress this rule via .preflightrc.json: "disabledRules": ["geofencing-foreground-service"].',
6688
6813
  policyUrl: "https://support.google.com/googleplay/android-developer/answer/16926792"
6689
6814
  });
@@ -6773,7 +6898,7 @@ var RESTRICTED_PERMISSIONS = [
6773
6898
  permission: "android.permission.READ_CALL_LOG",
6774
6899
  severity: "critical",
6775
6900
  title: "READ_CALL_LOG requires declaration form",
6776
- message: "READ_CALL_LOG is restricted to default phone/assistant handler apps. As of July 15, 2026, account verification via phone call is no longer an accepted use case for the Call Log permissions.",
6901
+ message: "READ_CALL_LOG is restricted to default phone/assistant handler apps. From January 27, 2027, account verification via phone call is no longer an accepted use case for the Call Log permissions.",
6777
6902
  suggestion: "Remove READ_CALL_LOG unless your app is a default phone or assistant handler. For phone-number verification, use the Digital Credentials API; for one-time codes, use the SMS Retriever API instead of reading the call log.",
6778
6903
  policyUrl: "https://support.google.com/googleplay/android-developer/answer/10208820"
6779
6904
  },
@@ -6916,14 +7041,14 @@ var permissionsScanner = {
6916
7041
  ].filter((p) => manifest.permissions.includes(p) && !allowed.has(p));
6917
7042
  if (contactsPerms.length > 0) {
6918
7043
  const names = contactsPerms.map((p) => p.split(".").pop()).join(", ");
6919
- const enforced = manifest.targetSdk >= 37 ? ` Your app targets API ${manifest.targetSdk}, so this is enforced.` : "";
7044
+ const enforced = manifest.targetSdk >= 37 ? ` Your app targets API ${manifest.targetSdk}, so this applies to it.` : "";
6920
7045
  findings.push({
6921
7046
  scanner: "permissions",
6922
7047
  ruleId: "contacts-permission-broad",
6923
7048
  severity: "warning",
6924
7049
  title: "Broad contacts access requires migration to Contact Picker",
6925
- message: `Your app declares ${names}. Google Play's Contacts Permissions policy requires the Android Contact Picker (Intent.ACTION_PICK_CONTACTS) unless broad access is core to your app. Mandatory for apps targeting Android 17 (API 37+) on October 28, 2026.${enforced}`,
6926
- suggestion: "Migrate to the Android Contact Picker (Intent.ACTION_PICK_CONTACTS) for user-initiated contact selection, or file a Permissions Declaration Form before October 28, 2026 if the picker cannot serve your core functionality. Remove READ_CONTACTS/WRITE_CONTACTS unless your app is a dialer, messaging, or contacts management app.",
7050
+ message: `Your app declares ${names}. Google Play's Contacts Permissions policy requires the Android Contact Picker (Intent.ACTION_PICK_CONTACTS) unless broad access is core to your app and you submit a Play Console declaration. Mandatory for apps targeting Android 17 (API 37+) on January 27, 2027.${enforced}`,
7051
+ suggestion: "Migrate to the Android Contact Picker (Intent.ACTION_PICK_CONTACTS) for user-initiated contact selection, or submit the contacts declaration in Play Console before January 27, 2027 if the picker cannot serve your core functionality. Remove READ_CONTACTS/WRITE_CONTACTS unless your app is a dialer, messaging, or contacts management app.",
6927
7052
  policyUrl: "https://support.google.com/googleplay/android-developer/answer/16935362"
6928
7053
  });
6929
7054
  }
@@ -6935,8 +7060,8 @@ var permissionsScanner = {
6935
7060
  ruleId: "location-minimal-scope",
6936
7061
  severity: targetsApi37 ? "warning" : "info",
6937
7062
  title: "Precise location \u2014 consider the location button (minimum scope)",
6938
- message: `Your app declares ACCESS_FINE_LOCATION. Google Play's Location Permissions policy makes the Android location button the recommended minimum scope for transactional (one-time) precise location. Mandatory for apps targeting Android 17 (API 37+) on October 28, 2026.${targetsApi37 ? ` Your app targets API ${manifest.targetSdk}.` : ""}`,
6939
- suggestion: 'For one-time/transactional precise location, use the system location button instead of requesting ACCESS_FINE_LOCATION up front. Only keep continuous fine location if navigation, fitness, or a similar feature is core functionality. Suppress via .preflightrc.json "disabledRules": ["location-minimal-scope"] if fine location is essential.',
7063
+ message: `Your app declares ACCESS_FINE_LOCATION. Google Play's Location Permissions policy requires the Android location button for one-time or infrequent precise location, and a Play Console declaration explaining why coarse location or the location button is not enough. Mandatory for apps targeting Android 17 (API 37+) on January 27, 2027.${targetsApi37 ? ` Your app targets API ${manifest.targetSdk}.` : ""}`,
7064
+ suggestion: 'For one-time/transactional precise location, use the system location button instead of requesting ACCESS_FINE_LOCATION up front. Only keep continuous fine location if navigation, fitness, or a similar feature is core functionality, and complete the precise location declaration in Play Console. Suppress via .preflightrc.json "disabledRules": ["location-minimal-scope"] if fine location is essential.',
6940
7065
  policyUrl: "https://support.google.com/googleplay/android-developer/answer/17033915"
6941
7066
  });
6942
7067
  }
@@ -10404,6 +10529,7 @@ export {
10404
10529
  analyzeReviews2 as analyzeReviews,
10405
10530
  annotateListResult,
10406
10531
  applyReleaseNotes,
10532
+ assertRateThreshold,
10407
10533
  assignRelease,
10408
10534
  batchGetOrders,
10409
10535
  batchSyncInAppProducts,
@@ -10558,6 +10684,7 @@ export {
10558
10684
  isValidStatsDimension,
10559
10685
  isVersionedNotesDir,
10560
10686
  isZip,
10687
+ latestWorstValue,
10561
10688
  lintListing,
10562
10689
  lintListings,
10563
10690
  lintLocalListings,
@@ -10609,6 +10736,7 @@ export {
10609
10736
  pushLeaderboardConfigs,
10610
10737
  pushListings,
10611
10738
  readListingsFromDir,
10739
+ readMetricValue,
10612
10740
  readReleaseNotesForVersion,
10613
10741
  readReleaseNotesFromDir,
10614
10742
  readinessBadgeColor,
@@ -10698,6 +10826,7 @@ export {
10698
10826
  waitForBundleProcessing,
10699
10827
  watchVitalsWithAutoHalt,
10700
10828
  wordDiff,
10829
+ worstSliceAverage,
10701
10830
  writeAuditLog,
10702
10831
  writeListingsToDir,
10703
10832
  writeMigrationOutput