kasy-cli 1.31.2 → 1.31.4
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/lib/commands/check.js +3 -1
- package/lib/commands/new.js +10 -2
- package/lib/scaffold/generate.js +40 -17
- package/lib/scaffold/shared/fcm-service-account.js +117 -28
- package/lib/scaffold/shared/post-build.js +15 -6
- package/lib/utils/i18n/messages-en.js +1 -0
- package/lib/utils/i18n/messages-es.js +1 -0
- package/lib/utils/i18n/messages-pt.js +1 -0
- package/package.json +1 -1
package/lib/commands/check.js
CHANGED
|
@@ -186,7 +186,9 @@ async function runCheck(options = {}) {
|
|
|
186
186
|
if (options.fix && firebaseProjectId) {
|
|
187
187
|
const fixSpinner = ui.spinner();
|
|
188
188
|
fixSpinner.start(t('check.fbSak.spin'));
|
|
189
|
-
const fcmResult = await createFcmServiceAccountKey(firebaseProjectId
|
|
189
|
+
const fcmResult = await createFcmServiceAccountKey(firebaseProjectId, {
|
|
190
|
+
onProgress: (stage) => { if (stage === 'orgPolicy') fixSpinner.message(t('new.fcm.adjustingOrgPolicy')); },
|
|
191
|
+
});
|
|
190
192
|
fixSpinner.stop(t('check.fbSak.spinDone'));
|
|
191
193
|
|
|
192
194
|
if (fcmResult.ok) {
|
package/lib/commands/new.js
CHANGED
|
@@ -546,6 +546,10 @@ function printStepResult(step, lang = 'pt') {
|
|
|
546
546
|
const detail = step.detail ? kleur.dim(` — ${step.detail.split('\n')[0]}`) : '';
|
|
547
547
|
if (step.ok) {
|
|
548
548
|
ui.log.success(`${label}${detail}`);
|
|
549
|
+
} else if (step.warn) {
|
|
550
|
+
// Non-fatal: something we deliberately deferred (e.g. pub get on a slow
|
|
551
|
+
// connection). Show it as a warning, not a scary red error.
|
|
552
|
+
ui.log.warn(`${label}${detail}`);
|
|
549
553
|
} else {
|
|
550
554
|
ui.log.error(`${label}${detail}`);
|
|
551
555
|
}
|
|
@@ -1904,7 +1908,9 @@ async function runNew(directory, { language: langHint = null, backend: backendHi
|
|
|
1904
1908
|
if (answers.firebaseProjectId) {
|
|
1905
1909
|
const fcmSpinner = ui.timedSpinner();
|
|
1906
1910
|
fcmSpinner.start(tr('new.fcm.generating'));
|
|
1907
|
-
const fcmResult = await createFcmServiceAccountKey(answers.firebaseProjectId
|
|
1911
|
+
const fcmResult = await createFcmServiceAccountKey(answers.firebaseProjectId, {
|
|
1912
|
+
onProgress: (stage) => { if (stage === 'orgPolicy') fcmSpinner.message(tr('new.fcm.adjustingOrgPolicy')); },
|
|
1913
|
+
});
|
|
1908
1914
|
fcmSpinner.stop(tr('new.fcm.generating'));
|
|
1909
1915
|
if (fcmResult.ok) {
|
|
1910
1916
|
fcmServiceAccountJson = fcmResult.json;
|
|
@@ -1990,7 +1996,9 @@ async function runNew(directory, { language: langHint = null, backend: backendHi
|
|
|
1990
1996
|
if (backend === 'api' && answers.firebaseProjectId) {
|
|
1991
1997
|
const fcmSpinner = ui.timedSpinner();
|
|
1992
1998
|
fcmSpinner.start(tr('new.fcm.generating'));
|
|
1993
|
-
const fcmResult = await createFcmServiceAccountKey(answers.firebaseProjectId
|
|
1999
|
+
const fcmResult = await createFcmServiceAccountKey(answers.firebaseProjectId, {
|
|
2000
|
+
onProgress: (stage) => { if (stage === 'orgPolicy') fcmSpinner.message(tr('new.fcm.adjustingOrgPolicy')); },
|
|
2001
|
+
});
|
|
1994
2002
|
fcmSpinner.stop(tr('new.fcm.generating'));
|
|
1995
2003
|
if (fcmResult.ok) {
|
|
1996
2004
|
try {
|
package/lib/scaffold/generate.js
CHANGED
|
@@ -319,22 +319,40 @@ async function generateProject(targetDir, backend, options, hooks = {}) {
|
|
|
319
319
|
// ── 2. Post-build comum ────────────────────────────────────────────────────
|
|
320
320
|
onProgress('pub-get');
|
|
321
321
|
const pubGetResult = await pubGet(targetDir);
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
detail:
|
|
336
|
-
|
|
337
|
-
|
|
322
|
+
|
|
323
|
+
// pub get is the prerequisite for the codegen steps (slang, build_runner): they
|
|
324
|
+
// run `dart run` inside the project and can't work without dependencies. If it
|
|
325
|
+
// didn't finish (a very slow/flaky Windows download, even after retries), DON'T
|
|
326
|
+
// abort the whole project — skip only the codegen steps and keep going, so
|
|
327
|
+
// flutterfire + backend config still complete. The user finishes with one
|
|
328
|
+
// `flutter pub get` later, which the IDE / `kasy run` does automatically. That
|
|
329
|
+
// turns a hard "Command failed" into a near-complete project plus one next step.
|
|
330
|
+
if (pubGetResult.ok) {
|
|
331
|
+
steps.push({ name: 'pub-get', ok: true });
|
|
332
|
+
|
|
333
|
+
onProgress('slang');
|
|
334
|
+
const slangResult = await slangGenerate(targetDir);
|
|
335
|
+
steps.push({ name: 'slang', ok: slangResult.ok, detail: slangResult.ok ? null : slangResult.error });
|
|
336
|
+
if (!slangResult.ok) return { steps };
|
|
337
|
+
|
|
338
|
+
onProgress('build-runner');
|
|
339
|
+
const buildRunnerResult = await buildRunner(targetDir);
|
|
340
|
+
steps.push({
|
|
341
|
+
name: 'build-runner',
|
|
342
|
+
ok: buildRunnerResult.ok,
|
|
343
|
+
detail: buildRunnerResult.ok ? null : buildRunnerResult.error,
|
|
344
|
+
});
|
|
345
|
+
if (!buildRunnerResult.ok) return { steps };
|
|
346
|
+
} else {
|
|
347
|
+
const deferredMsg = {
|
|
348
|
+
en: 'not downloaded yet (slow connection); open the project in VS Code to install',
|
|
349
|
+
pt: 'não baixou agora (conexão lenta); abra o projeto no VS Code que ele instala',
|
|
350
|
+
es: 'no se descargó ahora (conexión lenta); abre el proyecto en VS Code para instalar',
|
|
351
|
+
};
|
|
352
|
+
steps.push({ name: 'pub-get', ok: false, warn: true, detail: deferredMsg[language] || deferredMsg.en });
|
|
353
|
+
steps.push({ name: 'slang', skipped: true });
|
|
354
|
+
steps.push({ name: 'build-runner', skipped: true });
|
|
355
|
+
}
|
|
338
356
|
|
|
339
357
|
onProgress('flutterfire');
|
|
340
358
|
const ffResult = await flutterfireConfigure(targetDir, firebaseProjectId, { includeWeb });
|
|
@@ -391,7 +409,12 @@ async function generateProject(targetDir, backend, options, hooks = {}) {
|
|
|
391
409
|
// `kasy new` without needing `kasy deploy` first. Fast (<30s), billing-free.
|
|
392
410
|
// Without this the project gets Firebase's default deny-all rules, causing
|
|
393
411
|
// every Firestore read to throw permission-denied and the app to log the user out.
|
|
394
|
-
|
|
412
|
+
//
|
|
413
|
+
// FIREBASE BACKEND ONLY. Supabase/API projects do have a Firebase project, but
|
|
414
|
+
// ONLY for FCM push — there is no Firestore there, so deploying firestore:rules
|
|
415
|
+
// is incoherent (it targets a database that doesn't exist) and just hangs for
|
|
416
|
+
// minutes. The data layer for those backends is Supabase/the REST API.
|
|
417
|
+
if (backend === 'firebase' && firebaseProjectId) {
|
|
395
418
|
onProgress('firestore-rules');
|
|
396
419
|
const rulesResult = await deployFirestoreRules(targetDir, firebaseProjectId);
|
|
397
420
|
steps.push({ name: 'firestore-rules', ok: rulesResult.ok, detail: rulesResult.ok ? null : rulesResult.error });
|
|
@@ -110,6 +110,72 @@ async function allowServiceAccountKeyCreation(projectId) {
|
|
|
110
110
|
return { ok: true };
|
|
111
111
|
}
|
|
112
112
|
|
|
113
|
+
/**
|
|
114
|
+
* True when a gcloud command failed because the account lacks the required IAM
|
|
115
|
+
* permission (as opposed to a transient/propagation error). Used to tell "you
|
|
116
|
+
* can't do this" apart from "try again in a moment".
|
|
117
|
+
*/
|
|
118
|
+
function isPermissionError(result) {
|
|
119
|
+
const blob = `${result.error || ''} ${result.stderr || ''}`;
|
|
120
|
+
return /PERMISSION_DENIED|does not have permission|Permission .*denied|\bforbidden\b|\b403\b/i.test(blob);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* The active gcloud account as an IAM member string (e.g. "user:me@gmail.com").
|
|
125
|
+
* Service-account logins get the "serviceAccount:" prefix instead.
|
|
126
|
+
*/
|
|
127
|
+
async function getCurrentGcloudMember() {
|
|
128
|
+
const result = await run('gcloud config get-value account');
|
|
129
|
+
if (!result.ok) return null;
|
|
130
|
+
const email = (result.stdout || '').trim();
|
|
131
|
+
if (!email || email === '(unset)') return null;
|
|
132
|
+
const prefix = email.endsWith('.gserviceaccount.com') ? 'serviceAccount' : 'user';
|
|
133
|
+
return `${prefix}:${email}`;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* The organization ID a project belongs to, or null if the project has no org
|
|
138
|
+
* (most personal Google accounts) — in which case there is no org policy to lift.
|
|
139
|
+
*/
|
|
140
|
+
async function getProjectOrganizationId(projectId) {
|
|
141
|
+
const result = await run(
|
|
142
|
+
`gcloud projects get-ancestors ${projectId} --format="value(id,type)"`
|
|
143
|
+
);
|
|
144
|
+
if (!result.ok) return null;
|
|
145
|
+
for (const line of (result.stdout || '').split('\n')) {
|
|
146
|
+
const [id, type] = line.trim().split(/\s+/);
|
|
147
|
+
if (type === 'organization' && id) return id;
|
|
148
|
+
}
|
|
149
|
+
return null;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Grants roles/orgpolicy.policyAdmin to an org member. Org OWNERS lack this role
|
|
154
|
+
* by default but CAN grant it (organizationAdmin includes setIamPolicy), which is
|
|
155
|
+
* what lets the lift below succeed. Returns ok:false if the account can't grant it
|
|
156
|
+
* (e.g. it's a non-owner member of a company org).
|
|
157
|
+
*/
|
|
158
|
+
async function grantOrgPolicyAdmin(orgId, member) {
|
|
159
|
+
const result = await run(
|
|
160
|
+
`gcloud organizations add-iam-policy-binding ${orgId}` +
|
|
161
|
+
` --member="${member}" --role="roles/orgpolicy.policyAdmin" --condition=None`
|
|
162
|
+
);
|
|
163
|
+
return { ok: result.ok, error: result.stderr || result.error };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Removes the policyAdmin role we granted, returning the org to its original
|
|
168
|
+
* posture. Best effort — cleanup must never fail the overall key creation.
|
|
169
|
+
* The project-level unenforce stays (it needs no admin role and is the minimum
|
|
170
|
+
* required to rotate the key later).
|
|
171
|
+
*/
|
|
172
|
+
async function removeOrgPolicyAdmin(orgId, member) {
|
|
173
|
+
await run(
|
|
174
|
+
`gcloud organizations remove-iam-policy-binding ${orgId}` +
|
|
175
|
+
` --member="${member}" --role="roles/orgpolicy.policyAdmin" --condition=None`
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
|
|
113
179
|
/**
|
|
114
180
|
* Generates a new private key for the Firebase Admin SDK service account and returns
|
|
115
181
|
* the JSON content as a string. The temporary key file is deleted immediately after reading.
|
|
@@ -122,12 +188,13 @@ async function allowServiceAccountKeyCreation(projectId) {
|
|
|
122
188
|
* @param {string} projectId - Firebase/GCP project ID
|
|
123
189
|
* @returns {{ ok: boolean, json?: string, saEmail?: string, policyAdjusted?: boolean, errorKind?: string, error?: string }}
|
|
124
190
|
*/
|
|
125
|
-
async function createFcmServiceAccountKey(projectId) {
|
|
191
|
+
async function createFcmServiceAccountKey(projectId, { onProgress } = {}) {
|
|
126
192
|
if (!projectId || !projectId.trim()) {
|
|
127
193
|
return { ok: false, error: 'projectId is required' };
|
|
128
194
|
}
|
|
129
195
|
|
|
130
196
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
197
|
+
const notify = (stage) => { try { if (onProgress) onProgress(stage); } catch (_) {} };
|
|
131
198
|
|
|
132
199
|
// On a brand-new project the Firebase Admin SDK service account is provisioned
|
|
133
200
|
// asynchronously after Firebase is added, and the IAM role grant also needs a
|
|
@@ -161,42 +228,64 @@ async function createFcmServiceAccountKey(projectId) {
|
|
|
161
228
|
` --project=${projectId.trim()}`
|
|
162
229
|
);
|
|
163
230
|
|
|
164
|
-
//
|
|
165
|
-
//
|
|
166
|
-
//
|
|
167
|
-
// before giving up, instead of leaving push half-configured.
|
|
231
|
+
// Phase 1 — try a direct create. On personal Google accounts with no
|
|
232
|
+
// organization there is no blocking policy, so this just works once the IAM
|
|
233
|
+
// role settles. A couple of quick retries cover that role propagation.
|
|
168
234
|
let keyResult;
|
|
169
|
-
let
|
|
170
|
-
const maxAttempts = 6;
|
|
171
|
-
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
235
|
+
for (let attempt = 1; attempt <= 3; attempt++) {
|
|
172
236
|
keyResult = await createKey();
|
|
173
|
-
if (keyResult.ok) break;
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
237
|
+
if (keyResult.ok || !isKeyCreationBlockedByOrgPolicy(keyResult)) break;
|
|
238
|
+
if (attempt < 3) await sleep(5000);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// Phase 2 — the org blocks SA keys (default on orgs created since 2024). Lift the
|
|
242
|
+
// constraint for THIS project only. Lifting needs roles/orgpolicy.policyAdmin,
|
|
243
|
+
// which even org OWNERS lack by default — but an owner CAN grant it to itself
|
|
244
|
+
// (organizationAdmin includes setIamPolicy). So: try to lift; if denied, self-grant
|
|
245
|
+
// policyAdmin and retry the lift; roll the grant back in Phase 3.
|
|
246
|
+
let policyAdjusted = false;
|
|
247
|
+
let selfGranted = null; // { orgId, member } to roll back afterward
|
|
248
|
+
if (!keyResult.ok && isKeyCreationBlockedByOrgPolicy(keyResult)) {
|
|
249
|
+
notify('orgPolicy');
|
|
250
|
+
let lift = await allowServiceAccountKeyCreation(projectId.trim());
|
|
251
|
+
|
|
252
|
+
if (!lift.ok && isPermissionError(lift)) {
|
|
253
|
+
const orgId = await getProjectOrganizationId(projectId.trim());
|
|
254
|
+
const member = await getCurrentGcloudMember();
|
|
255
|
+
if (orgId && member) {
|
|
256
|
+
const grant = await grantOrgPolicyAdmin(orgId, member);
|
|
257
|
+
if (grant.ok) {
|
|
258
|
+
selfGranted = { orgId, member };
|
|
259
|
+
// The grant takes a moment to propagate before the lift is accepted.
|
|
260
|
+
for (let attempt = 1; attempt <= 8 && !lift.ok; attempt++) {
|
|
261
|
+
await sleep(15000);
|
|
262
|
+
lift = await allowServiceAccountKeyCreation(projectId.trim());
|
|
263
|
+
}
|
|
186
264
|
}
|
|
187
|
-
policyAdjusted = true;
|
|
188
265
|
}
|
|
189
|
-
if (attempt < maxAttempts) await sleep(15000);
|
|
190
|
-
continue;
|
|
191
266
|
}
|
|
192
|
-
|
|
267
|
+
|
|
268
|
+
if (lift.ok) {
|
|
269
|
+
policyAdjusted = true;
|
|
270
|
+
// The lift itself takes ~1-2 min to take effect — retry the create with backoff.
|
|
271
|
+
for (let attempt = 1; attempt <= 10; attempt++) {
|
|
272
|
+
keyResult = await createKey();
|
|
273
|
+
if (keyResult.ok) break;
|
|
274
|
+
if (attempt < 10) await sleep(15000);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
193
277
|
}
|
|
194
278
|
|
|
279
|
+
// Phase 3 — roll back the broad policyAdmin grant so the organization returns to
|
|
280
|
+
// its original posture. The project-level unenforce stays: it needs no admin role
|
|
281
|
+
// and is the minimum required to rotate this key later.
|
|
282
|
+
if (selfGranted) await removeOrgPolicyAdmin(selfGranted.orgId, selfGranted.member);
|
|
283
|
+
|
|
195
284
|
if (!keyResult.ok) {
|
|
196
285
|
await fs.remove(tmpPath).catch(() => {});
|
|
197
|
-
//
|
|
198
|
-
//
|
|
199
|
-
// to the generic "
|
|
286
|
+
// "Ask an org admin" only when we genuinely could not lift the policy (e.g. a
|
|
287
|
+
// non-owner member of a company org). If we DID lift it but creation is still
|
|
288
|
+
// propagating, fall through to the generic "try again" message, not a perms blame.
|
|
200
289
|
if (!policyAdjusted && isKeyCreationBlockedByOrgPolicy(keyResult)) {
|
|
201
290
|
return { ok: false, errorKind: 'orgPolicyBlocked', error: keyResult.error };
|
|
202
291
|
}
|
|
@@ -37,12 +37,21 @@ async function run(cmd, cwd, timeout) {
|
|
|
37
37
|
}
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
-
async function pubGet(projectDir) {
|
|
41
|
-
//
|
|
42
|
-
//
|
|
43
|
-
//
|
|
44
|
-
//
|
|
45
|
-
|
|
40
|
+
async function pubGet(projectDir, { onAttempt } = {}) {
|
|
41
|
+
// The FIRST `flutter pub get` on a fresh machine downloads the whole dependency
|
|
42
|
+
// tree (firebase, supabase, revenuecat, stripe, sentry…). On a slow Windows
|
|
43
|
+
// connection — or with antivirus scanning every cached file — a single attempt
|
|
44
|
+
// can time out (the user saw 15 min, then "Command failed"). The pub cache is
|
|
45
|
+
// global and persists between attempts, so each retry resumes where the last one
|
|
46
|
+
// stopped and tends to finish fast. A retry also clears a hung download. Try a
|
|
47
|
+
// few times with a shorter per-attempt ceiling before giving up.
|
|
48
|
+
let last;
|
|
49
|
+
for (let attempt = 1; attempt <= 2; attempt++) {
|
|
50
|
+
if (onAttempt) onAttempt(attempt);
|
|
51
|
+
last = await run('flutter pub get', projectDir, 600_000); // 10 min per attempt
|
|
52
|
+
if (last.ok) return last;
|
|
53
|
+
}
|
|
54
|
+
return last;
|
|
46
55
|
}
|
|
47
56
|
|
|
48
57
|
async function slangGenerate(projectDir) {
|
|
@@ -746,6 +746,7 @@ module.exports = {
|
|
|
746
746
|
'new.fcm.ok': 'generated automatically',
|
|
747
747
|
'new.fcm.failSupabase': 'not generated (GCP permission still propagating); set FIREBASE_SERVICE_ACCOUNT_JSON in your Supabase secrets',
|
|
748
748
|
'new.fcm.failApi': 'not generated (GCP permission still propagating); run the command again in a few minutes',
|
|
749
|
+
'new.fcm.adjustingOrgPolicy': 'Enabling organization permissions for push (may take 1-2 min)…',
|
|
749
750
|
'new.fcm.policyLifted': 'Enabled service account key creation for this project (required for push notifications)',
|
|
750
751
|
'new.fcm.orgPolicyBlocked': 'not generated: your Google Cloud organization blocks service account keys. Ask an organization admin to allow it, or generate the key in the Firebase Console (Project settings > Service accounts > Generate new private key)',
|
|
751
752
|
'new.sha1.registering': 'Registering SHA-1 for Google Sign-In (Android)…',
|
|
@@ -746,6 +746,7 @@ module.exports = {
|
|
|
746
746
|
'new.fcm.ok': 'generada automáticamente',
|
|
747
747
|
'new.fcm.failSupabase': 'no generada (permiso de GCP aún propagando); define FIREBASE_SERVICE_ACCOUNT_JSON en los secrets de Supabase',
|
|
748
748
|
'new.fcm.failApi': 'no generada (permiso de GCP aún propagando); ejecuta el comando de nuevo en unos minutos',
|
|
749
|
+
'new.fcm.adjustingOrgPolicy': 'Habilitando permisos de la organización para el push (puede tardar 1-2 min)…',
|
|
749
750
|
'new.fcm.policyLifted': 'Habilitada la creación de claves de cuenta de servicio en este proyecto (necesario para las notificaciones push)',
|
|
750
751
|
'new.fcm.orgPolicyBlocked': 'no generada: tu organización en Google Cloud bloquea las claves de cuenta de servicio. Pide a un administrador de la organización que lo permita, o genera la clave en la Firebase Console (Configuración del proyecto > Cuentas de servicio > Generar nueva clave privada)',
|
|
751
752
|
'new.sha1.registering': 'Registrando SHA-1 para Google Sign-In (Android)…',
|
|
@@ -746,6 +746,7 @@ module.exports = {
|
|
|
746
746
|
'new.fcm.ok': 'gerada automaticamente',
|
|
747
747
|
'new.fcm.failSupabase': 'não gerada (permissão do GCP ainda propagando); defina FIREBASE_SERVICE_ACCOUNT_JSON nos secrets do Supabase',
|
|
748
748
|
'new.fcm.failApi': 'não gerada (permissão do GCP ainda propagando); rode o comando de novo em alguns minutos',
|
|
749
|
+
'new.fcm.adjustingOrgPolicy': 'Liberando permissões da organização para o push (pode levar 1-2 min)…',
|
|
749
750
|
'new.fcm.policyLifted': 'Liberada a criação de chaves de service account neste projeto (necessário para as notificações push)',
|
|
750
751
|
'new.fcm.orgPolicyBlocked': 'não gerada: sua organização no Google Cloud bloqueia chaves de service account. Peça a um administrador da organização para liberar, ou gere a chave no Firebase Console (Configurações do projeto > Contas de serviço > Gerar nova chave privada)',
|
|
751
752
|
'new.sha1.registering': 'Registrando SHA-1 para Google Sign-In (Android)…',
|