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: {