Skip to content

Commit ad06531

Browse files
feat(ai): send X-Firebase-AppVersion when data collection enabled (#9173)
1 parent b7efa8c commit ad06531

18 files changed

Lines changed: 432 additions & 6 deletions

File tree

docs/app/utils.mdx

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,24 @@ async function bootstrap() {
4545
}
4646
```
4747

48+
# App Version
49+
50+
`appVersion` returns the host app's marketing version string: Android `versionName` or iOS
51+
`CFBundleShortVersionString`. Use it when you need the installed app version without pulling in a
52+
separate package.
53+
54+
> Be aware, `appVersion` is available on Android and iOS only. On web and other non-native platforms
55+
> it is `undefined` (including when the native value is empty).
56+
57+
```js
58+
import { utils } from '@react-native-firebase/app';
59+
60+
const version = utils().appVersion;
61+
if (version) {
62+
console.log('App version:', version);
63+
}
64+
```
65+
4866
# Android - Checking Play Services
4967

5068
It is useful to know if the Android device has play services available, and what to do in response to certain use cases:

jest.setup.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,7 @@ jest.doMock('react-native', () => {
8888
NativeRNFBTurboUtils: {
8989
getConstants: () => ({
9090
isRunningInTestLab: false,
91+
appVersion: '1.0.0',
9192
MAIN_BUNDLE: '/',
9293
CACHES_DIRECTORY: '/cache',
9394
DOCUMENT_DIRECTORY: '/documents',
@@ -97,6 +98,7 @@ jest.doMock('react-native', () => {
9798
MOVIES_DIRECTORY: '/movies',
9899
}),
99100
isRunningInTestLab: false,
101+
appVersion: '1.0.0',
100102
MAIN_BUNDLE: '/',
101103
CACHES_DIRECTORY: '/cache',
102104
DOCUMENT_DIRECTORY: '/documents',

packages/ai/__tests__/request.test.ts

Lines changed: 177 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,15 @@
1515
* limitations under the License.
1616
*/
1717
import { describe, expect, it, jest, afterEach } from '@jest/globals';
18-
import { RequestUrl, Task, getHeaders, makeRequest } from '../lib/requests/request';
18+
import {
19+
RequestUrl,
20+
Task,
21+
TemplateRequestUrl,
22+
ServerPromptTemplateTask,
23+
getHeaders,
24+
getTemplateHeaders,
25+
makeRequest,
26+
} from '../lib/requests/request';
1927
import { ApiSettings } from '../lib/types/internal';
2028
import { DEFAULT_API_VERSION } from '../lib/constants';
2129
import { AIErrorCode } from '../lib/types';
@@ -282,6 +290,174 @@ describe('request methods', () => {
282290
const headers = await getHeaders(fakeUrl);
283291
expect(headers.has('Authorization')).toBe(false);
284292
});
293+
294+
it('adds X-Firebase-Appid and X-Firebase-AppVersion when collection enabled', async () => {
295+
const fakeUrl = new RequestUrl(
296+
'models/model-name',
297+
Task.GENERATE_CONTENT,
298+
{
299+
apiKey: 'key',
300+
project: 'myproject',
301+
appId: 'my-appid',
302+
location: 'moon',
303+
backend: new VertexAIBackend(),
304+
automaticDataCollectionEnabled: true,
305+
appVersion: '2.3.4',
306+
},
307+
true,
308+
{},
309+
);
310+
const headers = await getHeaders(fakeUrl);
311+
expect(headers.get('X-Firebase-Appid')).toBe('my-appid');
312+
expect(headers.get('X-Firebase-AppVersion')).toBe('2.3.4');
313+
});
314+
315+
it('omits AppId and AppVersion when collection disabled', async () => {
316+
const fakeUrl = new RequestUrl(
317+
'models/model-name',
318+
Task.GENERATE_CONTENT,
319+
{
320+
apiKey: 'key',
321+
project: 'myproject',
322+
appId: 'my-appid',
323+
location: 'moon',
324+
backend: new VertexAIBackend(),
325+
automaticDataCollectionEnabled: false,
326+
appVersion: '2.3.4',
327+
},
328+
true,
329+
{},
330+
);
331+
const headers = await getHeaders(fakeUrl);
332+
expect(headers.has('X-Firebase-Appid')).toBe(false);
333+
expect(headers.has('X-Firebase-AppVersion')).toBe(false);
334+
});
335+
336+
it('adds AppId but omits AppVersion when version missing', async () => {
337+
const fakeUrl = new RequestUrl(
338+
'models/model-name',
339+
Task.GENERATE_CONTENT,
340+
{
341+
apiKey: 'key',
342+
project: 'myproject',
343+
appId: 'my-appid',
344+
location: 'moon',
345+
backend: new VertexAIBackend(),
346+
automaticDataCollectionEnabled: true,
347+
},
348+
true,
349+
{},
350+
);
351+
const headers = await getHeaders(fakeUrl);
352+
expect(headers.get('X-Firebase-Appid')).toBe('my-appid');
353+
expect(headers.has('X-Firebase-AppVersion')).toBe(false);
354+
});
355+
356+
it('omits AppVersion when version is empty string', async () => {
357+
const fakeUrl = new RequestUrl(
358+
'models/model-name',
359+
Task.GENERATE_CONTENT,
360+
{
361+
apiKey: 'key',
362+
project: 'myproject',
363+
appId: 'my-appid',
364+
location: 'moon',
365+
backend: new VertexAIBackend(),
366+
automaticDataCollectionEnabled: true,
367+
appVersion: '',
368+
},
369+
true,
370+
{},
371+
);
372+
const headers = await getHeaders(fakeUrl);
373+
expect(headers.get('X-Firebase-Appid')).toBe('my-appid');
374+
expect(headers.has('X-Firebase-AppVersion')).toBe(false);
375+
});
376+
});
377+
378+
describe('getTemplateHeaders', () => {
379+
it('adds X-Firebase-Appid and X-Firebase-AppVersion when collection enabled', async () => {
380+
const fakeUrl = new TemplateRequestUrl(
381+
'template-id',
382+
ServerPromptTemplateTask.TEMPLATE_GENERATE_CONTENT,
383+
{
384+
apiKey: 'key',
385+
project: 'myproject',
386+
appId: 'my-appid',
387+
location: 'moon',
388+
backend: new VertexAIBackend(),
389+
automaticDataCollectionEnabled: true,
390+
appVersion: '9.9.9',
391+
},
392+
false,
393+
{},
394+
);
395+
const headers = await getTemplateHeaders(fakeUrl);
396+
expect(headers.get('X-Firebase-Appid')).toBe('my-appid');
397+
expect(headers.get('X-Firebase-AppVersion')).toBe('9.9.9');
398+
});
399+
400+
it('omits AppId and AppVersion when collection disabled', async () => {
401+
const fakeUrl = new TemplateRequestUrl(
402+
'template-id',
403+
ServerPromptTemplateTask.TEMPLATE_GENERATE_CONTENT,
404+
{
405+
apiKey: 'key',
406+
project: 'myproject',
407+
appId: 'my-appid',
408+
location: 'moon',
409+
backend: new VertexAIBackend(),
410+
automaticDataCollectionEnabled: false,
411+
appVersion: '9.9.9',
412+
},
413+
false,
414+
{},
415+
);
416+
const headers = await getTemplateHeaders(fakeUrl);
417+
expect(headers.has('X-Firebase-Appid')).toBe(false);
418+
expect(headers.has('X-Firebase-AppVersion')).toBe(false);
419+
});
420+
421+
it('adds AppId but omits AppVersion when version missing', async () => {
422+
const fakeUrl = new TemplateRequestUrl(
423+
'template-id',
424+
ServerPromptTemplateTask.TEMPLATE_GENERATE_CONTENT,
425+
{
426+
apiKey: 'key',
427+
project: 'myproject',
428+
appId: 'my-appid',
429+
location: 'moon',
430+
backend: new VertexAIBackend(),
431+
automaticDataCollectionEnabled: true,
432+
},
433+
false,
434+
{},
435+
);
436+
const headers = await getTemplateHeaders(fakeUrl);
437+
expect(headers.get('X-Firebase-Appid')).toBe('my-appid');
438+
expect(headers.has('X-Firebase-AppVersion')).toBe(false);
439+
});
440+
441+
it('omits AppVersion when version is empty string', async () => {
442+
const fakeUrl = new TemplateRequestUrl(
443+
'template-id',
444+
ServerPromptTemplateTask.TEMPLATE_GENERATE_CONTENT,
445+
{
446+
apiKey: 'key',
447+
project: 'myproject',
448+
appId: 'my-appid',
449+
location: 'moon',
450+
backend: new VertexAIBackend(),
451+
automaticDataCollectionEnabled: true,
452+
appVersion: '',
453+
},
454+
false,
455+
{},
456+
);
457+
const headers = await getTemplateHeaders(fakeUrl);
458+
expect(headers.get('X-Firebase-Appid')).toBe('my-appid');
459+
expect(headers.has('X-Firebase-AppVersion')).toBe(false);
460+
});
285461
});
286462

287463
describe('makeRequest', () => {

packages/ai/__tests__/utils.test.ts

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,13 +15,23 @@
1515
* limitations under the License.
1616
*/
1717
import { describe, expect, it, jest } from '@jest/globals';
18-
import { type ReactNativeFirebase } from '@react-native-firebase/app';
18+
import { getUtils, type ReactNativeFirebase } from '@react-native-firebase/app';
1919
import { AI, AIErrorCode } from '../lib/public-types';
2020
import { AIError } from '../lib/errors';
2121
import { VertexAIBackend } from '../lib/backend';
2222
import { AIService } from '../lib/service';
2323
import { initApiSettings } from '../lib/models/utils';
2424

25+
jest.mock('@react-native-firebase/app', () => {
26+
const actual = jest.requireActual<typeof import('@react-native-firebase/app')>(
27+
'@react-native-firebase/app',
28+
);
29+
return {
30+
...actual,
31+
getUtils: jest.fn((...args: Parameters<typeof actual.getUtils>) => actual.getUtils(...args)),
32+
};
33+
});
34+
2535
const fakeAI: AI = {
2636
app: {
2737
name: 'DEFAULT',
@@ -159,4 +169,24 @@ describe('initApiSettings', function () {
159169
expect((e as AIError).code).toBe(AIErrorCode.NO_APP_ID);
160170
}
161171
});
172+
173+
it('populates appVersion from app utils when available', function () {
174+
const apiSettings = initApiSettings(fakeAI);
175+
expect(apiSettings.appVersion).toBe('1.0.0');
176+
});
177+
178+
it('omits appVersion when getUtils throws', function () {
179+
jest.mocked(getUtils).mockImplementationOnce(() => {
180+
throw new Error('native utils unavailable');
181+
});
182+
183+
const apiSettings = initApiSettings(fakeAI);
184+
expect(apiSettings.appVersion).toBeUndefined();
185+
expect(apiSettings.apiKey).toBe('key');
186+
expect(apiSettings.project).toBe('my-project');
187+
expect(apiSettings.appId).toBe('my-appid');
188+
expect(apiSettings.location).toBe('us-central1');
189+
expect(apiSettings.automaticDataCollectionEnabled).toBe(true);
190+
expect(apiSettings.backend).toBe(fakeAI.backend);
191+
});
162192
});

packages/ai/lib/models/utils.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
*/
1717
import type { AppCheck } from '@react-native-firebase/app-check';
1818
import { getLimitedUseToken, getToken } from '@react-native-firebase/app-check';
19+
import { getUtils } from '@react-native-firebase/app';
1920
import { AIError } from '../errors';
2021
import { AI, AIErrorCode } from '../public-types';
2122
import { AIService } from '../service';
@@ -53,6 +54,16 @@ export function initApiSettings(ai: AI): ApiSettings {
5354
backend: ai.backend,
5455
};
5556

57+
try {
58+
// Utils is single-app (hasMultiAppSupport: false); do not pass ai.app.
59+
const appVersion = getUtils().appVersion;
60+
if (appVersion) {
61+
apiSettings.appVersion = appVersion;
62+
}
63+
} catch {
64+
// Native utils unavailable (web / non-native) — omit appVersion.
65+
}
66+
5667
const appCheck = ai.appCheck as AppCheck;
5768
if (appCheck) {
5869
apiSettings.getAppCheckToken = () =>

packages/ai/lib/requests/request.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,9 @@ export async function getHeaders(url: RequestUrl): Promise<Headers> {
163163
headers.append('x-goog-api-key', url.apiSettings.apiKey);
164164
if (url.apiSettings.automaticDataCollectionEnabled) {
165165
headers.append('X-Firebase-Appid', url.apiSettings.appId);
166+
if (url.apiSettings.appVersion) {
167+
headers.append('X-Firebase-AppVersion', url.apiSettings.appVersion);
168+
}
166169
}
167170
if (url.apiSettings.getAppCheckToken) {
168171
let appCheckToken;
@@ -194,6 +197,9 @@ export async function getTemplateHeaders(url: TemplateRequestUrl): Promise<Heade
194197
headers.append('x-goog-api-key', url.apiSettings.apiKey);
195198
if (url.apiSettings.automaticDataCollectionEnabled) {
196199
headers.append('X-Firebase-Appid', url.apiSettings.appId);
200+
if (url.apiSettings.appVersion) {
201+
headers.append('X-Firebase-AppVersion', url.apiSettings.appVersion);
202+
}
197203
}
198204
if (url.apiSettings.getAppCheckToken) {
199205
let appCheckToken;

packages/ai/lib/types/internal.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,10 @@ export interface ApiSettings {
2626
*/
2727
location: string;
2828
automaticDataCollectionEnabled?: boolean;
29+
/**
30+
* Host app marketing version when available (Android versionName / iOS CFBundleShortVersionString).
31+
*/
32+
appVersion?: string;
2933
backend: Backend;
3034
getAuthToken?: () => Promise<string>;
3135
getAppCheckToken?: () => Promise<AppCheckTokenResult>;

packages/app/android/src/reactnative/java/io/invertase/firebase/app/generated/java/com/facebook/fbreact/specs/NativeRNFBTurboUtilsSpec.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,8 @@ public NativeRNFBTurboUtilsSpec(ReactApplicationContext reactContext) {
5959
"EXTERNAL_DIRECTORY",
6060
"EXTERNAL_STORAGE_DIRECTORY",
6161
"FILE_TYPE_DIRECTORY",
62-
"FILE_TYPE_REGULAR"
62+
"FILE_TYPE_REGULAR",
63+
"appVersion"
6364
));
6465
Set<String> undeclaredConstants = new HashSet<>(constants.keySet());
6566
undeclaredConstants.removeAll(obligatoryFlowConstants);

packages/app/android/src/reactnative/java/io/invertase/firebase/utils/NativeRNFBTurboUtils.java

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,16 @@ public void androidMakePlayServicesAvailable(Promise promise) {
135135
}
136136
}
137137

138+
private static String getAppVersionName(Context context) {
139+
try {
140+
return context.getPackageManager().getPackageInfo(context.getPackageName(), 0).versionName;
141+
} catch (Exception error) {
142+
// Optional metadata: prefer omit over crashing on null context / unexpected PM failures.
143+
Log.d(TAG, "getAppVersionName", error);
144+
return null;
145+
}
146+
}
147+
138148
private int isGooglePlayServicesAvailable() {
139149
GoogleApiAvailability gapi = GoogleApiAvailability.getInstance();
140150
return gapi.isGooglePlayServicesAvailable(getReactApplicationContext());
@@ -166,6 +176,11 @@ protected Map<String, Object> getTypedExportedConstants() {
166176
constants.put("isRunningInTestLab", isRunningInTestLab());
167177

168178
Context context = getReactApplicationContext();
179+
String appVersion = getAppVersionName(context);
180+
if (appVersion != null && !appVersion.isEmpty()) {
181+
constants.put("appVersion", appVersion);
182+
}
183+
169184
constants.put(KEY_MAIN_BUNDLE, "");
170185
constants.put(KEY_LIBRARY_DIRECTORY, context.getFilesDir().getAbsolutePath());
171186
constants.put(KEY_TEMP_DIRECTORY, context.getCacheDir().getAbsolutePath());

0 commit comments

Comments
 (0)