diff --git a/okf-bundle/ci-workflows/android.md b/okf-bundle/ci-workflows/android.md index b8342cad99..ed2c2616c8 100644 --- a/okf-bundle/ci-workflows/android.md +++ b/okf-bundle/ci-workflows/android.md @@ -74,5 +74,6 @@ Under load, Jet may run only a small prefix before mocha-remote desync, often af | Empty Jacoco XML (~235 bytes) | No `.ec` pulled — check post-e2e logs | | `adb reverse --remove` in Detox logs | Expected on 1006; should be warn-only after Detox patch | | Detox red, tests green in log | Pre-patch: teardown adb error; re-run or check patch applied | +| Emulator offline / hung / duplicate instance | Warm quickboot snapshot restore; `tests/.detoxrc.js` sets `bootArgs: '-no-snapshot-load -no-snapshot-save'` for cold boot when Detox launches TestingAVD | | `codecov/project/android-native` fail | Jacoco XML not uploaded — check post-e2e logs and Codecov Uploads tab for `android-native` flag | | FIS 503 / `Too many server requests` / RC cascade | Live cloud quota (shared project) — not Android-specific; see [cloud API quota triage](../testing/firebase-testing-project.md#ci-triage-cloud-api-quota-pressure) | diff --git a/okf-bundle/testing/change-authoring-workflow.md b/okf-bundle/testing/change-authoring-workflow.md index 3314913b42..b7fa2da66b 100644 --- a/okf-bundle/testing/change-authoring-workflow.md +++ b/okf-bundle/testing/change-authoring-workflow.md @@ -146,6 +146,17 @@ flowchart TD Step detail: [running e2e § unit-focused iteration loop](running-e2e.md#unit-focused-tier-iteration-loop). + + +### Platform SDK bridge contracts (blocking) + +Before changing native bridge code that calls a **platform SDK** (Firebase Android/iOS, OS APIs, vendor SDKs, etc.): + +1. **Read each platform's official API signature and docs independently** — do not assume Android, iOS, and Web behave the same because the RNFB bridge presents a unified JavaScript surface. +2. **Verify null vs empty string** — many SDKs treat absent values (`null`/`nil`) and empty strings (`""`) differently; map bridge fields to the SDK parameter the docs specify for "absent" values. +3. **Do not apply defensive parity** — fixing platform A does not justify the same change on platform B without checking B's contract (e.g. `@Nullable` vs `nonnull`, optional vs required). +4. **Record evidence** — link or cite the reference URL / header signature in the work queue note or triage report when the fix is native-only on one platform. + ### E2e diagnosis escalation diff --git a/packages/auth/__tests__/auth.test.ts b/packages/auth/__tests__/auth.test.ts index 4e50e6cd83..31253d04f8 100644 --- a/packages/auth/__tests__/auth.test.ts +++ b/packages/auth/__tests__/auth.test.ts @@ -413,6 +413,26 @@ describe('Auth', function () { expect(credential?.idToken).toBe('id-token'); }); + it('GoogleAuthProvider.credential maps id-token-only credentials for native bridge', function () { + const credential = GoogleAuthProvider.credential('google-id-token', null); + expect(credential.providerId).toBe('google.com'); + expect(credential.signInMethod).toBe('google.com'); + expect(credential.idToken).toBe('google-id-token'); + expect(credential.accessToken).toBeUndefined(); + expect(credential.token).toBe('google-id-token'); + expect(credential.secret).toBe(''); + }); + + it('GoogleAuthProvider.credential maps access-token-only credentials for native bridge', function () { + const credential = GoogleAuthProvider.credential(null, 'google-access-token'); + expect(credential.providerId).toBe('google.com'); + expect(credential.signInMethod).toBe('google.com'); + expect(credential.accessToken).toBe('google-access-token'); + expect(credential.idToken).toBeUndefined(); + expect(credential.token).toBe(''); + expect(credential.secret).toBe('google-access-token'); + }); + it('PhoneAuthCredential.fromJSON deserializes phone credentials', function () { const credential = PhoneAuthCredential.fromJSON({ verificationId: 'vid', diff --git a/packages/auth/android/src/main/java/io/invertase/firebase/auth/ReactNativeFirebaseAuthModule.java b/packages/auth/android/src/main/java/io/invertase/firebase/auth/ReactNativeFirebaseAuthModule.java index 1f58c0f974..3981765a78 100644 --- a/packages/auth/android/src/main/java/io/invertase/firebase/auth/ReactNativeFirebaseAuthModule.java +++ b/packages/auth/android/src/main/java/io/invertase/firebase/auth/ReactNativeFirebaseAuthModule.java @@ -2046,7 +2046,12 @@ private AuthCredential getCredentialForProvider( case "facebook.com": return FacebookAuthProvider.getCredential(authToken); case "google.com": - return GoogleAuthProvider.getCredential(authToken, authSecret); + // Firebase Android rejects empty tokens; id-token-only and access-token-only flows require + // null. + // https://firebase.google.com/docs/reference/android/com/google/firebase/auth/GoogleAuthProvider#getCredential(java.lang.String,java.lang.String) + String googleIdToken = (authToken == null || authToken.isEmpty()) ? null : authToken; + String googleAccessToken = (authSecret == null || authSecret.isEmpty()) ? null : authSecret; + return GoogleAuthProvider.getCredential(googleIdToken, googleAccessToken); case "twitter.com": return TwitterAuthProvider.getCredential(authToken, authSecret); case "github.com": diff --git a/packages/auth/e2e/provider.e2e.js b/packages/auth/e2e/provider.e2e.js index 771004fb25..ddeed7f580 100644 --- a/packages/auth/e2e/provider.e2e.js +++ b/packages/auth/e2e/provider.e2e.js @@ -236,6 +236,70 @@ describe('auth() -> Providers', function () { 'At least one of ID token and access token must be non-null', ); }); + + it('should accept id-token-only credentials (Credential Manager)', function () { + const { GoogleAuthProvider } = authModular; + + const token = 'google-id-token-only'; + const credential = GoogleAuthProvider.credential(token, null); + credential.providerId.should.equal('google.com'); + credential.signInMethod.should.equal('google.com'); + credential.token.should.equal(token); + credential.secret.should.equal(''); + credential.idToken.should.equal(token); + should.equal(credential.accessToken, undefined); + }); + + it('should accept access-token-only credentials', function () { + const { GoogleAuthProvider } = authModular; + + const accessToken = 'google-access-token-only'; + const credential = GoogleAuthProvider.credential(null, accessToken); + credential.providerId.should.equal('google.com'); + credential.signInMethod.should.equal('google.com'); + credential.token.should.equal(''); + credential.secret.should.equal(accessToken); + credential.accessToken.should.equal(accessToken); + should.equal(credential.idToken, undefined); + }); + + it('should reach Android native signInWithCredential for id-token-only Google credentials', async function () { + if (!Platform.android) { + this.skip(); + } + const { getApp } = modular; + const { GoogleAuthProvider, signInWithCredential, getAuth } = authModular; + const defaultAuth = getAuth(getApp()); + const credential = GoogleAuthProvider.credential('google-id-token-only-e2e', null); + + try { + await signInWithCredential(defaultAuth, credential); + throw new Error('Did not error.'); + } catch (error) { + error.code.should.be.a.String(); + error.code.should.not.equal('auth/unknown'); + String(error.message).should.not.match(/accessToken cannot be empty/i); + } + }); + + it('should reach Android native signInWithCredential for access-token-only Google credentials', async function () { + if (!Platform.android) { + this.skip(); + } + const { getApp } = modular; + const { GoogleAuthProvider, signInWithCredential, getAuth } = authModular; + const defaultAuth = getAuth(getApp()); + const credential = GoogleAuthProvider.credential(null, 'google-access-token-only-e2e'); + + try { + await signInWithCredential(defaultAuth, credential); + throw new Error('Did not error.'); + } catch (error) { + error.code.should.be.a.String(); + error.code.should.not.equal('auth/unknown'); + String(error.message).should.not.match(/idToken cannot be empty/i); + } + }); }); describe('GOOGLE_SIGN_IN_METHOD', function () { diff --git a/tests/.detoxrc.js b/tests/.detoxrc.js index be9388399e..ecb1fabb4e 100644 --- a/tests/.detoxrc.js +++ b/tests/.detoxrc.js @@ -62,6 +62,9 @@ module.exports = { device: { avdName: 'TestingAVD', }, + // Cold boot: do not load/save AVD snapshots (warm quickboot is unreliable locally). + bootArgs: '-no-snapshot-load -no-snapshot-save', + readonly: true, }, }, configurations: {